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']);