diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3d48b7f46..d050fe078 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+- [#347](https://github.com/os2display/display-api-service/pull/347)
+ - Added onFlush listener to handle ManyToMany collection changes for relations checksum propagation.
+ - Added command to refresh relation checksums.
+
## [2.6.0] - 2025-12-05
- [#330](https://github.com/os2display/display-api-service/pull/330)
diff --git a/src/Command/Checksum/RecalculateChecksumCommand.php b/src/Command/Checksum/RecalculateChecksumCommand.php
new file mode 100644
index 000000000..43d5b53a9
--- /dev/null
+++ b/src/Command/Checksum/RecalculateChecksumCommand.php
@@ -0,0 +1,158 @@
+addOption(self::OPTION_TENANT, null, InputOption::VALUE_REQUIRED, 'Filter by tenant key')
+ ->addOption(self::OPTION_MODIFIED_AFTER, null, InputOption::VALUE_REQUIRED, 'Filter by modified_at >= date (e.g. "2024-01-01" or "2024-01-01 12:00:00")')
+ ->setHelp(<<<'HELP'
+The %command.name% command recalculates relation checksums for slides and media,
+then propagates the changes up the entity tree.
+
+ php %command.full_name%
+
+You can filter by tenant key and/or modification date:
+
+ php %command.full_name% --tenant=ABC
+ php %command.full_name% --modified-after="2024-01-01"
+ php %command.full_name% --tenant=ABC --modified-after="2024-01-01 12:00:00"
+
+Without any filters, all slides and media will be recalculated.
+HELP)
+ ;
+ }
+
+ #[\Override]
+ public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void
+ {
+ if ($input->mustSuggestOptionValuesFor(self::OPTION_TENANT)) {
+ $tenants = $this->tenantRepository->findAll();
+ foreach ($tenants as $tenant) {
+ $suggestions->suggestValue($tenant->getTenantKey());
+ }
+ }
+ }
+
+ #[\Override]
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $stopwatch = new Stopwatch();
+ $stopwatch->start('checksum-recalculate');
+
+ $tenantKey = $input->getOption(self::OPTION_TENANT);
+ $modifiedAfterStr = $input->getOption(self::OPTION_MODIFIED_AFTER);
+
+ // Resolve tenant
+ $tenant = null;
+ if (null !== $tenantKey) {
+ $tenant = $this->tenantRepository->findOneBy(['tenantKey' => $tenantKey]);
+ if (null === $tenant) {
+ $io->error(sprintf('Tenant with key "%s" not found.', $tenantKey));
+
+ return Command::FAILURE;
+ }
+ $io->info(sprintf('Filtering by tenant: %s', $tenantKey));
+ }
+
+ // Parse date
+ $modifiedAfter = null;
+ if (null !== $modifiedAfterStr) {
+ try {
+ $modifiedAfter = new \DateTimeImmutable($modifiedAfterStr);
+ } catch (\Exception) {
+ $io->error(sprintf('Invalid date format: "%s". Use formats like "Y-m-d" or "Y-m-d H:i:s".', $modifiedAfterStr));
+
+ return Command::FAILURE;
+ }
+ $io->info(sprintf('Filtering by modified after: %s', $modifiedAfter->format('Y-m-d H:i:s')));
+ }
+
+ // Mark matching slides and media as changed using DQL UPDATE
+ $targetEntities = [
+ 'slide' => Slide::class,
+ 'media' => Media::class,
+ ];
+ $totalAffected = 0;
+
+ foreach ($targetEntities as $label => $entityClass) {
+ $qb = $this->entityManager->createQueryBuilder()
+ ->update($entityClass, 'e')
+ ->set('e.changed', ':changed')
+ ->setParameter('changed', true);
+
+ if (null !== $tenant) {
+ $qb->andWhere('e.tenant = :tenant')
+ ->setParameter('tenant', $tenant, Tenant::class);
+ }
+
+ if (null !== $modifiedAfter) {
+ $qb->andWhere('e.modifiedAt >= :modifiedAfter')
+ ->setParameter('modifiedAfter', $modifiedAfter, 'datetime_immutable');
+ }
+
+ $affected = $qb->getQuery()->execute();
+ $totalAffected += $affected;
+ $io->info(sprintf('Marked %d rows in "%s" as changed.', $affected, $label));
+ }
+
+ if (0 === $totalAffected) {
+ $io->warning('No rows matched the given filters. Nothing to recalculate.');
+
+ return Command::SUCCESS;
+ }
+
+ // Propagate checksums through entity tree
+ $io->info('Propagating checksums through entity tree...');
+ $this->calculator->execute(withWhereClause: true);
+
+ $event = $stopwatch->stop('checksum-recalculate');
+
+ $io->success(sprintf(
+ 'Checksums recalculated. %d rows marked. Elapsed: %.2f ms, Memory: %.2f MB',
+ $totalAffected,
+ $event->getDuration(),
+ $event->getMemory() / (1024 ** 2)
+ ));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/DataFixtures/Loader/DoctrineOrmLoaderDecorator.php b/src/DataFixtures/Loader/DoctrineOrmLoaderDecorator.php
index 3ae0f3e8e..f95bef641 100644
--- a/src/DataFixtures/Loader/DoctrineOrmLoaderDecorator.php
+++ b/src/DataFixtures/Loader/DoctrineOrmLoaderDecorator.php
@@ -6,6 +6,7 @@
use App\EventListener\RelationsChecksumListener;
use App\EventListener\TimestampableListener;
+use App\Service\RelationsChecksumCalculator;
use Doctrine\ORM\EntityManagerInterface;
use Hautelook\AliceBundle\Loader\DoctrineOrmLoader;
use Hautelook\AliceBundle\LoaderInterface as AliceBundleLoaderInterface;
@@ -28,6 +29,7 @@ class DoctrineOrmLoaderDecorator implements AliceBundleLoaderInterface, LoggerAw
{
public function __construct(
private readonly DoctrineOrmLoader $decorated,
+ private readonly RelationsChecksumCalculator $calculator,
) {}
public function load(Application $application, EntityManagerInterface $manager, array $bundles, string $environment, bool $append, bool $purgeWithTruncate, bool $noBundles = false): array
@@ -59,7 +61,7 @@ public function load(Application $application, EntityManagerInterface $manager,
$result = $this->decorated->load($application, $manager, $bundles, $environment, $append, $purgeWithTruncate, $noBundles);
// Apply the SQL statements from the disabled "postFlush" listener
- $this->applyRelationsModified($manager);
+ $this->applyRelationsModified();
// Re-enable listeners
$eventManager->addEventListener('postFlush', $relationsModifiedAtListener);
@@ -77,16 +79,8 @@ public function withLogger(LoggerInterface $logger): static
$this->decorated->withLogger($logger);
}
- private function applyRelationsModified(EntityManagerInterface $manager): void
+ private function applyRelationsModified(): void
{
- $connection = $manager->getConnection();
-
- $sqlQueries = RelationsChecksumListener::getUpdateRelationsAtQueries(withWhereClause: false);
-
- $rows = 0;
- foreach ($sqlQueries as $sqlQuery) {
- $stm = $connection->prepare($sqlQuery);
- $rows += $stm->executeStatement();
- }
+ $this->calculator->execute(withWhereClause: false);
}
}
diff --git a/src/EventListener/RelationsChecksumListener.php b/src/EventListener/RelationsChecksumListener.php
index f822c8109..66d67801a 100644
--- a/src/EventListener/RelationsChecksumListener.php
+++ b/src/EventListener/RelationsChecksumListener.php
@@ -18,7 +18,9 @@
use App\Entity\Tenant\ScreenGroup;
use App\Entity\Tenant\ScreenGroupCampaign;
use App\Entity\Tenant\Slide;
+use App\Service\RelationsChecksumCalculator;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
+use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreRemoveEventArgs;
@@ -53,12 +55,13 @@
#[AsDoctrineListener(event: Events::prePersist, priority: 100)]
#[AsDoctrineListener(event: Events::preUpdate)]
#[AsDoctrineListener(event: Events::preRemove)]
+#[AsDoctrineListener(event: Events::onFlush)]
#[AsDoctrineListener(event: Events::postFlush)]
class RelationsChecksumListener
{
- private const array CHECKSUM_TABLES = ['feed_source', 'feed', 'slide', 'media', 'theme', 'template', 'playlist_slide',
- 'playlist', 'screen_campaign', 'screen', 'screen_group_campaign', 'screen_group',
- 'playlist_screen_region', 'screen_layout_regions', 'screen_layout'];
+ public function __construct(
+ private readonly RelationsChecksumCalculator $calculator,
+ ) {}
/**
* PrePersist listener.
@@ -69,6 +72,8 @@ class RelationsChecksumListener
* function to work. If the field is left as null it will be serialized to the
* database as `[]` preventing JSON_SET from updating the field.
*
+ * @param PrePersistEventArgs $args
+ *
* @return void
*/
final public function prePersist(PrePersistEventArgs $args): void
@@ -158,6 +163,8 @@ final public function prePersist(PrePersistEventArgs $args): void
*
* On update set "changed" to "true" to ensure checksum changes propagate up the tree.
*
+ * @param PreUpdateEventArgs $args
+ *
* @return void
*/
final public function preUpdate(PreUpdateEventArgs $args): void
@@ -172,10 +179,12 @@ final public function preUpdate(PreUpdateEventArgs $args): void
/**
* PreRemove listener.
*
- * For "toMany" relations the "preUpdate" listener will not be called for the parent if a child relations
- * is deleted by calling remove() on the entity manager. We need to manually set "changed" on the parent
+ * For "toMany" relations the "preUpdate" listener will not be called for the parent if child relations
+ * are deleted by calling remove() on the entity manager. We need to manually set "changed" on the parent
* to "true" to ensure checksum changes propagate up the tree.
*
+ * @param PreRemoveEventArgs $args
+ *
* @return void
*/
final public function preRemove(PreRemoveEventArgs $args): void
@@ -215,313 +224,58 @@ final public function preRemove(PreRemoveEventArgs $args): void
}
/**
- * PostFlush listener.
+ * OnFlush listener.
*
- * Executes update SQL queries to set changed and relations_checksum fields in the database.
+ * This listener is used to set the "changed" flag on entities that have been modified since the last flush.
+ * This is required because Doctrine does not call the "preUpdate" listener for entities that only have
+ * collection changes.
*
- * @param PostFlushEventArgs $args the PostFlushEventArgs object containing information about the event
+ * @param OnFlushEventArgs $args
*
* @return void
- *
- * @throws \Doctrine\DBAL\Exception
*/
- final public function postFlush(PostFlushEventArgs $args): void
+ final public function onFlush(OnFlushEventArgs $args): void
{
- $connection = $args->getObjectManager()->getConnection();
-
- $sqlQueries = self::getUpdateRelationsAtQueries(withWhereClause: true);
-
- $connection->beginTransaction();
-
- try {
- foreach ($sqlQueries as $sqlQuery) {
- $stm = $connection->prepare($sqlQuery);
- $stm->executeStatement();
+ $em = $args->getObjectManager();
+ $uow = $em->getUnitOfWork();
+
+ // Catch ManyToMany collection adds/removes
+ foreach ($uow->getScheduledCollectionUpdates() as $collection) {
+ $owner = $collection->getOwner();
+ if ($owner instanceof RelationsChecksumInterface) {
+ $owner->setChanged(true);
+ $uow->recomputeSingleEntityChangeSet(
+ $em->getClassMetadata($owner::class),
+ $owner
+ );
}
-
- $connection->commit();
- } catch (\Exception $e) {
- $connection->rollBack();
- throw $e;
}
- }
-
- /**
- * Get an array of SQL update statements to update the changed and relationsModified fields.
- *
- * @param bool $withWhereClause
- * Should the statements include a where clause to limit the statement
- *
- * @return string[]
- * Array of SQL statements
- */
- public static function getUpdateRelationsAtQueries(bool $withWhereClause = true): array
- {
- // Set SQL update queries for the "relations checksum" fields on the parent (p), child (c) relationships up through the entity tree
- $sqlQueries = [];
-
- // Feed
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'feedSource', parentTable: 'feed', childTable: 'feed_source', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'slide', parentTable: 'feed', childTable: 'slide', parentTableId: 'id', childTableId: 'feed_id', withWhereClause: $withWhereClause);
-
- // Slide
- $sqlQueries[] = self::getManyToManyQuery(jsonKey: 'media', parentTable: 'slide', pivotTable: 'slide_media', childTable: 'media', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'theme', parentTable: 'slide', childTable: 'theme', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'templateInfo', parentTable: 'slide', childTable: 'template', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'feed', parentTable: 'slide', childTable: 'feed', withWhereClause: $withWhereClause);
- // PlaylistSlide
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'slide', parentTable: 'playlist_slide', childTable: 'slide', withWhereClause: $withWhereClause);
-
- // Playlist
- $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'slides', parentTable: 'playlist', childTable: 'playlist_slide', withWhereClause: $withWhereClause);
-
- // ScreenCampaign
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'campaign', parentTable: 'screen_campaign', childTable: 'playlist', parentTableId: 'campaign_id', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'screen', parentTable: 'screen_campaign', childTable: 'screen', withWhereClause: $withWhereClause);
-
- // ScreenGroupCampaign - campaign
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'campaign', parentTable: 'screen_group_campaign', childTable: 'playlist', parentTableId: 'campaign_id', withWhereClause: $withWhereClause);
-
- // ScreenGroup
- $sqlQueries[] = self::getManyToManyQuery(jsonKey: 'screens', parentTable: 'screen_group', pivotTable: 'screen_group_screen', childTable: 'screen', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'screenGroupCampaigns', parentTable: 'screen_group', childTable: 'screen_group_campaign', withWhereClause: $withWhereClause);
-
- // ScreenGroupCampaign - screenGroup
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'screenGroup', parentTable: 'screen_group_campaign', childTable: 'screen_group', withWhereClause: $withWhereClause);
-
- // PlaylistScreenRegion
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'playlist', parentTable: 'playlist_screen_region', childTable: 'playlist', withWhereClause: $withWhereClause);
-
- // ScreenLayoutRegions
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'regions', parentTable: 'screen_layout_regions', childTable: 'playlist_screen_region', parentTableId: 'id', childTableId: 'region_id', withWhereClause: $withWhereClause);
-
- // ScreenLayout
- $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'regions', parentTable: 'screen_layout', childTable: 'screen_layout_regions', withWhereClause: $withWhereClause);
-
- // Screen
- $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'campaigns', parentTable: 'screen', childTable: 'screen_campaign', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getToOneQuery(jsonKey: 'layout', parentTable: 'screen', childTable: 'screen_layout', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getOneToManyQuery(jsonKey: 'regions', parentTable: 'screen', childTable: 'playlist_screen_region', withWhereClause: $withWhereClause);
- $sqlQueries[] = self::getManyToManyQuery(jsonKey: 'inScreenGroups', parentTable: 'screen', pivotTable: 'screen_group_screen', childTable: 'screen_group', withWhereClause: $withWhereClause);
-
- // Add reset 'changed' fields queries
- $sqlQueries = array_merge($sqlQueries, self::getResetChangedQueries());
-
- return $sqlQueries;
- }
-
- /**
- * Get "One/ManyToOne" query.
- *
- * For a table (parent) that has a relation to another table (child) where we need to update the "relations_checksum"
- * field on the parent with a checksum of values from the child we need to join the tables and set the values.
- *
- * Basically we do: "Update parent, join child, set parent value = SHA(child values)"
- *
- * Example:
- * UPDATE slide p
- * INNER JOIN theme c ON p.theme_id = c.id
- * SET p.changed = 1,
- * p.relations_checksum = JSON_SET(p.relations_checksum, "$.theme", SHA1(CONCAT(c.id, c.version, c.relations_checksum)))
- * WHERE
- * p.changed = 1
- * OR c.changed = 1
- *
- * Explanation:
- * UPDATE parent table p, INNER JOIN child table c
- * - use INNER JOIN because the query only makes sense for result where both parent and child tables have rows
- * SET changed to 1 (true) to enable propagation up the tree.
- * SET the value for the relevant json key on the json object in p.relations_checksum to the checksum of the child id, version and relations checksum
- * WHERE either p.changed or c.changed is true
- * - Because we can't easily get a list of ID's of affected rows as we work up the tree we use the bool "changed" as clause in WHERE to limit to only update the rows just modified.
- *
- * @param string|null $parentTableId
- *
- * @return string
- */
- private static function getToOneQuery(string $jsonKey, string $parentTable, string $childTable, ?string $parentTableId = null, string $childTableId = 'id', bool $withWhereClause = true): string
- {
- // Set the column name to use for "ON" in the Join clause. By default, the child table name with "_id" appended.
- // E.g. "UPDATE feed p INNER JOIN feed_source c ON p.feed_source_id = c.id"
- $parentTableId ??= $childTable.'_id';
-
- // The base UPDATE query.
- // - Use INNER JON to only select rows that have a match in both parent and child tables
- // - Use JSON_SET to only INSERT/UPDATE the relevant key in the json object, not the whole field.
- $queryFormat = '
- UPDATE %s p
- INNER JOIN %s c ON p.%s = c.%s
- SET p.changed = 1,
- p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", SHA1(CONCAT(c.id, c.version, c.relations_checksum)))
- ';
-
- $query = sprintf($queryFormat, $parentTable, $childTable, $parentTableId, $childTableId, $jsonKey);
-
- // Add WHERE clause to only update rows that have been modified since ":modified_at"
- if ($withWhereClause) {
- $query .= ' WHERE p.changed = 1 OR c.changed = 1';
- }
-
- return $query;
- }
-
- /**
- * Get "OnetoMany" query.
- *
- * For a table (parent) that has a toMany relationship to another table (child) where we need to update the "relations_checksum"
- * field on the parent with a checksum of values from the child we need to join the tables and set the values.
- *
- * Example:
- * UPDATE
- * playlist p
- * INNER JOIN (
- * SELECT
- * c.playlist_id,
- * CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed,
- * SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum
- * FROM
- * playlist_slide c
- * GROUP BY
- * c.playlist_id
- * ) temp ON p.id = temp.playlist_id
- * SET p.changed = 1,
- * p.relations_checksum = JSON_SET(p.relations_checksum, "$.slides", temp.checksum)
- * WHERE p.changed = 1 OR temp.changed = 1
- *
- * Explanation:
- * Because this is a "to many" relation we need to GROUP_CONCAT values from the child relations. This is done in a temporary table
- * with GROUP BY parent id in the child table. This gives us just one child row for each parent row with a checksum from the relevant
- * fields across all child rows. We use a DISTINCT clause in GROUP_CONCAT to limit the length of the resulting value and avoid
- * illegal integer values.
- *
- * This temp table is then joined to the parent table to allow us to SET the p.changed and p.relations_checksum values on the parent.
- * - Because GROUP_CONCAT will give us all child rows "changed" as one, e.g. "00010001" we need "> 0" to evaluate to true/false
- * and then CAST that to "unsigned" to get a TINYINT (bool)
- * WHERE either p.changed or c.changed is true
- * - Because we can't easily get a list of ID's of affected rows as we work up the tree we use the bool "changed" as clause in
- * WHERE to limit to only update the rows just modified.
- *
- * @return string
- */
- private static function getOneToManyQuery(string $jsonKey, string $parentTable, string $childTable, bool $withWhereClause = true): string
- {
- $parentTableId = $parentTable.'_id';
-
- $queryFormat = '
- UPDATE
- %s p
- INNER JOIN (
- SELECT
- c.%s,
- CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed,
- SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum
- FROM
- %s c
- GROUP BY
- c.%s
- ) temp ON p.id = temp.%s
- SET p.changed = 1,
- p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", temp.checksum)
- ';
-
- $query = sprintf($queryFormat, $parentTable, $parentTableId, $childTable, $parentTableId, $parentTableId, $jsonKey);
-
- if ($withWhereClause) {
- $query .= ' WHERE p.changed = 1 OR temp.changed = 1';
+ foreach ($uow->getScheduledCollectionDeletions() as $collection) {
+ $owner = $collection->getOwner();
+ if ($owner instanceof RelationsChecksumInterface) {
+ $owner->setChanged(true);
+ $uow->recomputeSingleEntityChangeSet(
+ $em->getClassMetadata($owner::class),
+ $owner
+ );
+ }
}
-
- return $query;
}
/**
- * Get "many to many" query.
- *
- * For a table (parent) that has a relation to another table (child) through a pivot table where we need to update the "changed"
- * and "relations_checksum" fields on the parent with values from the child we need to join the tables and set the values.
- *
- * Basically we do:
- * "Update parent, join temp (SELECT checksum of c.id, c.version, c.relations_checksum from the child rows with GROUP_CONCAT), set parent values = child values"
- *
- * Example:
- * UPDATE
- * slide p
- * INNER JOIN (
- * SELECT
- * pivot.slide_id,
- * CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed,
- * SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum
- * FROM
- * slide_media pivot
- * INNER JOIN media c ON pivot.media_id = c.id
- * GROUP BY
- * pivot.slide_id
- * ) temp ON p.id = temp.slide_id
- * SET p.changed = 1,
- * p.relations_checksum = JSON_SET(p.relations_checksum, "$.media", temp.checksum)
- * WHERE p.changed = 1 OR temp.changed = 1
- *
- * Explanation:
- * Because this is a "to many" relation we need to GROUP_CONCAT values from the child relations. This is done in a temporary table
- * with GROUP BY parent id in the child table. This gives us just one child row for each parent row with a checksum from the relevant
- * fields across all child rows. We use a DISTINCT clause in GROUP_CONCAT to limit the length of the resulting value and avoid
- * illegal integer values.
+ * PostFlush listener.
*
- * This temp table is then joined to the parent table to allow us to SET the p.changed and p.relations_checksum values on the parent.
- * - Because GROUP_CONCAT will give us all child rows "changed" as one, e.g. "00010001" we need "> 0" to evaluate to true/false
- * and then CAST that to "unsigned" to get a TINYINT (bool)
- * WHERE either p.changed or c.changed is true
- * - Because we can't easily get a list of ID's of affected rows as we work up the tree we use the bool "changed" as clause in
- * WHERE to limit to only update the rows just modified.
+ * Executes update SQL queries to set changed and relations_checksum fields in the database.
*
- * @return string
- */
- private static function getManyToManyQuery(string $jsonKey, string $parentTable, string $pivotTable, string $childTable, bool $withWhereClause = true): string
- {
- $parentTableId = $parentTable.'_id';
- $childTableId = $childTable.'_id';
-
- $queryFormat = '
- UPDATE
- %s p
- INNER JOIN (
- SELECT
- pivot.%s,
- CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed,
- SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum
- FROM
- %s pivot
- INNER JOIN %s c ON pivot.%s = c.id
- GROUP BY
- pivot.%s
- ) temp ON p.id = temp.%s
- SET p.changed = 1,
- p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", temp.checksum)
- ';
-
- $query = sprintf($queryFormat, $parentTable, $parentTableId, $pivotTable, $childTable, $childTableId, $parentTableId, $parentTableId, $jsonKey);
- if ($withWhereClause) {
- $query .= ' WHERE p.changed = 1 OR temp.changed = 1';
- }
-
- return $query;
- }
-
- /**
- * Get an array of queries to reset all "changed" fields to 0.
+ * @param PostFlushEventArgs $args the PostFlushEventArgs object containing information about the event
*
- * Example:
- * UPDATE screen SET screen.changed = 0 WHERE screen.changed = 1;
+ * @return void
*
- * @return array
+ * @throws \Doctrine\DBAL\Exception
*/
- private static function getResetChangedQueries(): array
+ final public function postFlush(PostFlushEventArgs $args): void
{
- $queries = [];
- foreach (self::CHECKSUM_TABLES as $table) {
- $queries[] = sprintf('UPDATE %s SET changed = 0 WHERE changed = 1', $table);
- }
-
- return $queries;
+ $this->calculator->execute(withWhereClause: true);
}
}
diff --git a/src/Service/RelationsChecksumCalculator.php b/src/Service/RelationsChecksumCalculator.php
new file mode 100644
index 000000000..2e76cf999
--- /dev/null
+++ b/src/Service/RelationsChecksumCalculator.php
@@ -0,0 +1,287 @@
+ slide ->
+ * playlist_slide -> playlist -> screen).
+ *
+ * Extracted from RelationsChecksumListener to allow reuse in console commands
+ * and other contexts outside Doctrine lifecycle events.
+ */
+class RelationsChecksumCalculator
+{
+ public const array CHECKSUM_TABLES = ['feed_source', 'feed', 'slide', 'media', 'theme', 'template', 'playlist_slide',
+ 'playlist', 'screen_campaign', 'screen', 'screen_group_campaign', 'screen_group',
+ 'playlist_screen_region', 'screen_layout_regions', 'screen_layout'];
+
+ public function __construct(
+ private readonly Connection $connection,
+ ) {}
+
+ /**
+ * Execute all checksum propagation queries in a transaction.
+ *
+ * @param bool $withWhereClause limit updates to rows where changed = 1
+ *
+ * @throws \Doctrine\DBAL\Exception
+ */
+ public function execute(bool $withWhereClause = true): void
+ {
+ $sqlQueries = $this->getUpdateRelationsAtQueries(withWhereClause: $withWhereClause);
+
+ $this->connection->beginTransaction();
+
+ try {
+ foreach ($sqlQueries as $sqlQuery) {
+ $stm = $this->connection->prepare($sqlQuery);
+ $stm->executeStatement();
+ }
+
+ $this->connection->commit();
+ } catch (\Exception $e) {
+ $this->connection->rollBack();
+ throw $e;
+ }
+ }
+
+ /**
+ * Get an array of SQL update statements to update the changed and relationsModified fields.
+ *
+ * @param bool $withWhereClause
+ * Should the statements include a where clause to limit the statement
+ *
+ * @return string[]
+ * Array of SQL statements
+ */
+ public function getUpdateRelationsAtQueries(bool $withWhereClause = true): array
+ {
+ // Set SQL update queries for the "relations checksum" fields on the parent (p), child (c) relationships up through the entity tree
+ $sqlQueries = [];
+
+ // Feed
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'feedSource', parentTable: 'feed', childTable: 'feed_source', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'slide', parentTable: 'feed', childTable: 'slide', parentTableId: 'id', childTableId: 'feed_id', withWhereClause: $withWhereClause);
+
+ // Slide
+ $sqlQueries[] = $this->getManyToManyQuery(jsonKey: 'media', parentTable: 'slide', pivotTable: 'slide_media', childTable: 'media', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'theme', parentTable: 'slide', childTable: 'theme', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'templateInfo', parentTable: 'slide', childTable: 'template', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'feed', parentTable: 'slide', childTable: 'feed', withWhereClause: $withWhereClause);
+
+ // PlaylistSlide
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'slide', parentTable: 'playlist_slide', childTable: 'slide', withWhereClause: $withWhereClause);
+
+ // Playlist
+ $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'slides', parentTable: 'playlist', childTable: 'playlist_slide', withWhereClause: $withWhereClause);
+
+ // ScreenCampaign
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'campaign', parentTable: 'screen_campaign', childTable: 'playlist', parentTableId: 'campaign_id', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'screen', parentTable: 'screen_campaign', childTable: 'screen', withWhereClause: $withWhereClause);
+
+ // ScreenGroupCampaign - campaign
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'campaign', parentTable: 'screen_group_campaign', childTable: 'playlist', parentTableId: 'campaign_id', withWhereClause: $withWhereClause);
+
+ // ScreenGroup
+ $sqlQueries[] = $this->getManyToManyQuery(jsonKey: 'screens', parentTable: 'screen_group', pivotTable: 'screen_group_screen', childTable: 'screen', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'screenGroupCampaigns', parentTable: 'screen_group', childTable: 'screen_group_campaign', withWhereClause: $withWhereClause);
+
+ // ScreenGroupCampaign - screenGroup
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'screenGroup', parentTable: 'screen_group_campaign', childTable: 'screen_group', withWhereClause: $withWhereClause);
+
+ // PlaylistScreenRegion
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'playlist', parentTable: 'playlist_screen_region', childTable: 'playlist', withWhereClause: $withWhereClause);
+
+ // ScreenLayoutRegions
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'regions', parentTable: 'screen_layout_regions', childTable: 'playlist_screen_region', parentTableId: 'id', childTableId: 'region_id', withWhereClause: $withWhereClause);
+
+ // ScreenLayout
+ $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'regions', parentTable: 'screen_layout', childTable: 'screen_layout_regions', withWhereClause: $withWhereClause);
+
+ // Screen
+ $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'campaigns', parentTable: 'screen', childTable: 'screen_campaign', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getToOneQuery(jsonKey: 'layout', parentTable: 'screen', childTable: 'screen_layout', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getOneToManyQuery(jsonKey: 'regions', parentTable: 'screen', childTable: 'playlist_screen_region', withWhereClause: $withWhereClause);
+ $sqlQueries[] = $this->getManyToManyQuery(jsonKey: 'inScreenGroups', parentTable: 'screen', pivotTable: 'screen_group_screen', childTable: 'screen_group', withWhereClause: $withWhereClause);
+
+ // Add reset 'changed' fields queries
+ $sqlQueries = array_merge($sqlQueries, $this->getResetChangedQueries());
+
+ return $sqlQueries;
+ }
+
+ /**
+ * Get "One/ManyToOne" query.
+ *
+ * For a table (parent) that has a relation to another table (child) where we need to update the "relations_checksum"
+ * field on the parent with a checksum of values from the child we need to join the tables and set the values.
+ *
+ * Basically we do: "Update parent, join child, set parent value = SHA(child values)"
+ *
+ * Example:
+ * UPDATE slide p
+ * INNER JOIN theme c ON p.theme_id = c.id
+ * SET p.changed = 1,
+ * p.relations_checksum = JSON_SET(p.relations_checksum, "$.theme", SHA1(CONCAT(c.id, c.version, c.relations_checksum)))
+ * WHERE
+ * p.changed = 1
+ * OR c.changed = 1
+ *
+ * @param string|null $parentTableId
+ */
+ private function getToOneQuery(string $jsonKey, string $parentTable, string $childTable, ?string $parentTableId = null, string $childTableId = 'id', bool $withWhereClause = true): string
+ {
+ // Set the column name to use for "ON" in the Join clause. By default, the child table name with "_id" appended.
+ // E.g. "UPDATE feed p INNER JOIN feed_source c ON p.feed_source_id = c.id"
+ $parentTableId ??= $childTable.'_id';
+
+ // The base UPDATE query.
+ // - Use INNER JON to only select rows that have a match in both parent and child tables
+ // - Use JSON_SET to only INSERT/UPDATE the relevant key in the json object, not the whole field.
+ $queryFormat = '
+ UPDATE %s p
+ INNER JOIN %s c ON p.%s = c.%s
+ SET p.changed = 1,
+ p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", SHA1(CONCAT(c.id, c.version, c.relations_checksum)))
+ ';
+
+ $query = sprintf($queryFormat, $parentTable, $childTable, $parentTableId, $childTableId, $jsonKey);
+
+ // Add WHERE clause to only update rows that have been modified since ":modified_at"
+ if ($withWhereClause) {
+ $query .= ' WHERE p.changed = 1 OR c.changed = 1';
+ }
+
+ return $query;
+ }
+
+ /**
+ * Get "OnetoMany" query.
+ *
+ * For a table (parent) that has a toMany relationship to another table (child) where we need to update the "relations_checksum"
+ * field on the parent with a checksum of values from the child we need to join the tables and set the values.
+ *
+ * Example:
+ * UPDATE
+ * playlist p
+ * INNER JOIN (
+ * SELECT
+ * c.playlist_id,
+ * CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed,
+ * SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum
+ * FROM
+ * playlist_slide c
+ * GROUP BY
+ * c.playlist_id
+ * ) temp ON p.id = temp.playlist_id
+ * SET p.changed = 1,
+ * p.relations_checksum = JSON_SET(p.relations_checksum, "$.slides", temp.checksum)
+ * WHERE p.changed = 1 OR temp.changed = 1
+ */
+ private function getOneToManyQuery(string $jsonKey, string $parentTable, string $childTable, bool $withWhereClause = true): string
+ {
+ $parentTableId = $parentTable.'_id';
+
+ $queryFormat = '
+ UPDATE
+ %s p
+ INNER JOIN (
+ SELECT
+ c.%s,
+ CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed,
+ SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum
+ FROM
+ %s c
+ GROUP BY
+ c.%s
+ ) temp ON p.id = temp.%s
+ SET p.changed = 1,
+ p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", temp.checksum)
+ ';
+
+ $query = sprintf($queryFormat, $parentTable, $parentTableId, $childTable, $parentTableId, $parentTableId, $jsonKey);
+
+ if ($withWhereClause) {
+ $query .= ' WHERE p.changed = 1 OR temp.changed = 1';
+ }
+
+ return $query;
+ }
+
+ /**
+ * Get "many to many" query.
+ *
+ * For a table (parent) that has a relation to another table (child) through a pivot table where we need to update the "changed"
+ * and "relations_checksum" fields on the parent with values from the child we need to join the tables and set the values.
+ *
+ * Example:
+ * UPDATE
+ * slide p
+ * INNER JOIN (
+ * SELECT
+ * pivot.slide_id,
+ * CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed,
+ * SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum
+ * FROM
+ * slide_media pivot
+ * INNER JOIN media c ON pivot.media_id = c.id
+ * GROUP BY
+ * pivot.slide_id
+ * ) temp ON p.id = temp.slide_id
+ * SET p.changed = 1,
+ * p.relations_checksum = JSON_SET(p.relations_checksum, "$.media", temp.checksum)
+ * WHERE p.changed = 1 OR temp.changed = 1
+ */
+ private function getManyToManyQuery(string $jsonKey, string $parentTable, string $pivotTable, string $childTable, bool $withWhereClause = true): string
+ {
+ $parentTableId = $parentTable.'_id';
+ $childTableId = $childTable.'_id';
+
+ $queryFormat = '
+ UPDATE
+ %s p
+ INNER JOIN (
+ SELECT
+ pivot.%s,
+ CAST(GROUP_CONCAT(DISTINCT c.changed SEPARATOR "") > 0 AS UNSIGNED) changed,
+ SHA1(GROUP_CONCAT(c.id, c.version, c.relations_checksum)) checksum
+ FROM
+ %s pivot
+ INNER JOIN %s c ON pivot.%s = c.id
+ GROUP BY
+ pivot.%s
+ ) temp ON p.id = temp.%s
+ SET p.changed = 1,
+ p.relations_checksum = JSON_SET(p.relations_checksum, "$.%s", temp.checksum)
+ ';
+
+ $query = sprintf($queryFormat, $parentTable, $parentTableId, $pivotTable, $childTable, $childTableId, $parentTableId, $parentTableId, $jsonKey);
+ if ($withWhereClause) {
+ $query .= ' WHERE p.changed = 1 OR temp.changed = 1';
+ }
+
+ return $query;
+ }
+
+ /**
+ * Get an array of queries to reset all "changed" fields to 0.
+ *
+ * @return string[]
+ */
+ private function getResetChangedQueries(): array
+ {
+ $queries = [];
+ foreach (self::CHECKSUM_TABLES as $table) {
+ $queries[] = sprintf('UPDATE %s SET changed = 0 WHERE changed = 1', $table);
+ }
+
+ return $queries;
+ }
+}
diff --git a/tests/EventListener/RelationsChecksumListenerTest.php b/tests/EventListener/RelationsChecksumListenerTest.php
index c94aa6154..3d6ad19ee 100644
--- a/tests/EventListener/RelationsChecksumListenerTest.php
+++ b/tests/EventListener/RelationsChecksumListenerTest.php
@@ -478,6 +478,137 @@ public function testPersistScreen(): void
$this->assertFalse($playlistScreenRegion->isChanged());
}
+ public function testAddScreenToScreenGroupUpdatesChecksum(): void
+ {
+ $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']);
+ /** @var Tenant\ScreenGroup $screenGroup */
+ $screenGroup = $this->em->getRepository(Tenant\ScreenGroup::class)->findOneBy(['tenant' => $tenant]);
+ $beforeChecksum = $screenGroup->getRelationsChecksum()['screens'];
+
+ // Create a new screen to add
+ $screenLayout = $this->em->getRepository(ScreenLayout::class)->findOneBy(['title' => 'Full screen']);
+ $screen = new Tenant\Screen();
+ $screen->setTenant($tenant);
+ $screen->setScreenLayout($screenLayout);
+ $screen->setCreatedBy(self::class.'::testAddScreenToScreenGroupUpdatesChecksum()');
+
+ $this->em->persist($screen);
+ $this->em->flush();
+
+ // Collection-only change on screen group — no scalar property change
+ $screenGroup->addScreen($screen);
+ $this->em->flush();
+
+ $this->em->refresh($screenGroup);
+ $this->assertNotEquals($beforeChecksum, $screenGroup->getRelationsChecksum()['screens']);
+ $this->assertFalse($screenGroup->isChanged());
+ }
+
+ public function testRemoveScreenFromScreenGroupUpdatesChecksum(): void
+ {
+ $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']);
+ /** @var Tenant\ScreenGroup $screenGroup */
+ $screenGroup = $this->em->getRepository(Tenant\ScreenGroup::class)->findOneBy(['tenant' => $tenant]);
+ $this->assertGreaterThan(0, $screenGroup->getScreens()->count());
+
+ $beforeChecksum = $screenGroup->getRelationsChecksum()['screens'];
+
+ // Collection-only change — remove a screen without changing scalar properties
+ $screen = $screenGroup->getScreens()->first();
+ $screenGroup->removeScreen($screen);
+ $this->em->flush();
+
+ $this->em->refresh($screenGroup);
+ $this->assertNotEquals($beforeChecksum, $screenGroup->getRelationsChecksum()['screens']);
+ $this->assertFalse($screenGroup->isChanged());
+ }
+
+ public function testAddMediaToSlideUpdatesChecksum(): void
+ {
+ $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']);
+ /** @var Tenant\Slide $slide */
+ $slide = $this->em->getRepository(Tenant\Slide::class)->findOneBy(['tenant' => $tenant]);
+ $beforeChecksum = $slide->getRelationsChecksum()['media'];
+
+ // Find a media not already on this slide
+ $allMedia = $this->em->getRepository(Tenant\Media::class)->findBy(['tenant' => $tenant]);
+ $existingMediaIds = $slide->getMedia()->map(fn ($m) => $m->getId())->toArray();
+ $newMedia = null;
+ foreach ($allMedia as $candidate) {
+ if (!in_array($candidate->getId(), $existingMediaIds, true)) {
+ $newMedia = $candidate;
+ break;
+ }
+ }
+ $this->assertNotNull($newMedia, 'No available media found for test');
+
+ // Collection-only change on slide — no scalar property change
+ $slide->addMedium($newMedia);
+ $this->em->flush();
+
+ $this->em->refresh($slide);
+ $this->assertNotEquals($beforeChecksum, $slide->getRelationsChecksum()['media']);
+ $this->assertFalse($slide->isChanged());
+ }
+
+ public function testRemoveMediaFromSlideUpdatesChecksum(): void
+ {
+ $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']);
+ /** @var Tenant\Slide $slide */
+ $slide = $this->em->getRepository(Tenant\Slide::class)->findOneBy(['tenant' => $tenant]);
+ $this->assertGreaterThan(0, $slide->getMedia()->count());
+
+ $beforeChecksum = $slide->getRelationsChecksum()['media'];
+
+ // Collection-only change — remove media without changing scalar properties
+ $media = $slide->getMedia()->first();
+ $slide->removeMedium($media);
+ $this->em->flush();
+
+ $this->em->refresh($slide);
+ $this->assertNotEquals($beforeChecksum, $slide->getRelationsChecksum()['media']);
+ $this->assertFalse($slide->isChanged());
+ }
+
+ public function testManyToManyChangePropagatesUpTree(): void
+ {
+ $tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']);
+
+ /** @var Tenant\ScreenGroup $screenGroup */
+ $screenGroup = $this->em->getRepository(Tenant\ScreenGroup::class)->findOneBy(['tenant' => $tenant]);
+ $screenGroupBefore = $screenGroup->getRelationsChecksum()['screens'];
+
+ // Get a screen that belongs to this group — its inScreenGroups checksum should also change
+ /** @var Tenant\Screen $existingScreen */
+ $existingScreen = $screenGroup->getScreens()->first();
+ $this->assertNotNull($existingScreen);
+ $screenBefore = $existingScreen->getRelationsChecksum()['inScreenGroups'];
+
+ // Create a new screen to add to the group
+ $screenLayout = $this->em->getRepository(ScreenLayout::class)->findOneBy(['title' => 'Full screen']);
+ $newScreen = new Tenant\Screen();
+ $newScreen->setTenant($tenant);
+ $newScreen->setScreenLayout($screenLayout);
+ $newScreen->setCreatedBy(self::class.'::testManyToManyChangePropagatesUpTree()');
+
+ $this->em->persist($newScreen);
+ $this->em->flush();
+
+ // Collection-only change on screen group — triggers onFlush
+ $screenGroup->addScreen($newScreen);
+ $this->em->flush();
+
+ // Screen group checksum should update
+ $this->em->refresh($screenGroup);
+ $this->assertNotEquals($screenGroupBefore, $screenGroup->getRelationsChecksum()['screens']);
+ $this->assertFalse($screenGroup->isChanged());
+
+ // Existing screen's inScreenGroups checksum should propagate
+ $this->em->refresh($existingScreen);
+ $this->assertNotEquals($screenBefore, $existingScreen->getRelationsChecksum()['inScreenGroups']);
+ $this->assertFalse($existingScreen->isChanged());
+ }
+
public function testPlaylistSlideRelation(): void
{
$tenant = $this->em->getRepository(Tenant::class)->findOneBy(['tenantKey' => 'ABC']);