From f2c63258288a4c46243439b30cb88a9902a1194b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Thu, 3 Sep 2026 23:41:44 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(dbal):=20d=C3=A9clarer=20les=20tables?= =?UTF-8?q?=20du=20journal=20=C3=A0=20Doctrine,=20qui=20voulait=20les=20su?= =?UTF-8?q?pprimer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les quatre tables du pont n'étaient déclarées à Doctrine par aucun `configureSchema` ni écouteur `postGenerateSchema` — alors qu'un docbloc de `DurableSchema` affirmait « branché aussi sur configureSchema côté bundle ». `doctrine:schema:update` et `doctrine:migrations:diff` construisent le schéma attendu depuis les seules entités, n'y trouvent pas ces tables, et les traitent en orphelines : la migration générée les supprime, avec le journal et toutes les exécutions en vol. `DurableSchema::configureSchema()` reprend la signature des adaptateurs amont qui résolvent le même problème — `DoctrineDbalAdapter`, `DoctrineTransport`, `DoctrineDbalStore` — y compris le paramètre de sonde `$isSameDatabase`, et saute par table celles que le schéma porte déjà. L'écouteur du bundle ne s'adosse volontairement pas à `AbstractSchemaListener` : `filterSchemaChanges()` n'existe pas sur toute la plage ^6.4 à ^8.0 que le bundle accepte, et un appel fatal sur la version basse coûterait plus que ce qu'il apporte. Il ne déclare donc que sur la connexion même que l'ORM inspecte — ne rien déclarer laisse l'exploitant gérer ce schéma, là où déclarer à tort créerait des tables dans la mauvaise base. Le paramètre de sonde reste offert à qui sait trancher. `dbal.auto_setup` accompagne le correctif : dès que les migrations tiennent le schéma, les deux mécanismes écriraient l'un derrière l'autre. Défaut `true`, donc rien ne change sans le demander. C'est aussi l'interrupteur que le transport Doctrine expose. `doctrine/orm` est déclaré — require-dev à la racine comme illuminate/*, suggest sur le bundle — plutôt que référencé en douce : l'écouteur ne s'enregistre que si la classe d'événement existe. Suite unit : 1079 tests contre 1073 sur main, mêmes 4 erreurs d'environnement. PHPStan signale localement trois class.notFound sur Doctrine\ORM, de même nature que les deux sur Illuminate\Cache déjà présents sur main : le paquet est déclaré, il n'est pas installé sur ce poste. Refs: B4 de documentation/audit/ Co-Authored-By: Claude Opus 5 (1M context) --- composer.json | 1 + src/Bridge/Dbal/README.md | 24 +++- src/Bridge/Dbal/Schema/DurableSchema.php | 47 ++++++- .../DependencyInjection/Configuration.php | 1 + .../DependencyInjection/DurableExtension.php | 14 +++ .../SchemaListener/DurableSchemaListener.php | 47 +++++++ src/DurableBundle/composer.json | 3 +- .../Dbal/DurableSchemaDeclarationTest.php | 119 ++++++++++++++++++ 8 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 src/DurableBundle/SchemaListener/DurableSchemaListener.php create mode 100644 tests/unit/Bridge/Dbal/DurableSchemaDeclarationTest.php diff --git a/composer.json b/composer.json index 314b76b0..0d22c319 100644 --- a/composer.json +++ b/composer.json @@ -60,6 +60,7 @@ "friendsofphp/php-cs-fixer": "^3.0", "gplanchat/durable-phpstan": "@dev", "gplanchat/durable-rector": "@dev", + "doctrine/orm": "^2.19 || ^3.0", "illuminate/cache": "^11.0 || ^12.0 || ^13.0", "illuminate/database": "^11.0 || ^12.0 || ^13.0", "phpstan/phpstan": "^2.0", diff --git a/src/Bridge/Dbal/README.md b/src/Bridge/Dbal/README.md index 3fa8cf73..8d92a8b1 100644 --- a/src/Bridge/Dbal/README.md +++ b/src/Bridge/Dbal/README.md @@ -82,7 +82,29 @@ journal cannot have two sources of truth. ## Schema Tables are created on first write; there is no migration to run and no `doctrine/migrations` -dependency. To manage them yourself, call `DurableSchema::addToSchema()` from your own schema +dependency. + +**With Doctrine ORM installed, they are also declared to its tooling.** The bundle registers a +`postGenerateSchema` listener, so `doctrine:schema:update` and `doctrine:migrations:diff` know these +tables belong to the application. Without it they would look like orphans and a generated migration +would **drop them** — with the journal, every in-flight execution. + +The listener declares the tables only when the journal writes on the very `Connection` the ORM +inspects. Two distinct `Connection` objects can point at the same database, and proving it takes a +probe this bundle does not run; not declaring leaves that schema to you, whereas declaring wrongly +would create tables in the wrong database. Pass your own probe to +`DurableSchema::configureSchema($schema, $connection, $isSameDatabase)` if you need the other case. + +Once migrations own the schema, turn the lazy creation off — otherwise both mechanisms write +behind each other: + +```yaml +durable: + dbal: + auto_setup: false +``` + +To manage the tables entirely yourself, call `DurableSchema::addToSchema()` from your own schema provider and keep the table names in sync with the configuration above. The journal table has no `sequence` column — `readStream()` promises insertion order and the diff --git a/src/Bridge/Dbal/Schema/DurableSchema.php b/src/Bridge/Dbal/Schema/DurableSchema.php index 84953a7b..5fbdf906 100644 --- a/src/Bridge/Dbal/Schema/DurableSchema.php +++ b/src/Bridge/Dbal/Schema/DurableSchema.php @@ -9,10 +9,19 @@ use Doctrine\DBAL\Types\Types; /** - * Tables du backend DBAL : journal, métadonnées d'exécution, lien parent/enfant. + * Tables du backend DBAL : journal, métadonnées d'exécution, lien parent/enfant, catalogue de runs. * - * L'auto-création suit le modèle du transport Doctrine de Messenger : la première écriture - * crée ce qui manque. Pas de doctrine/migrations — la forme est figée par ce fichier. + * Deux façons de les obtenir, et le transport Doctrine de Messenger a les deux : + * + * - **L'auto-création** ({@see ensure()}) : la première écriture crée ce qui manque. Pratique en + * développement, et c'est le défaut. + * - **La déclaration** ({@see configureSchema()}) : les tables rejoignent le schéma que Doctrine + * construit, donc `doctrine:schema:update` et `doctrine:migrations:diff` les connaissent. Sans + * elle, l'outillage les voit comme orphelines et **génère leur suppression** — un journal + * d'exécutions durables effacé par une migration que personne n'a relue de près. + * + * Les deux ensemble se marchent dessus dès que les migrations tiennent le schéma : `auto_setup` + * éteint alors l'auto-création, comme le fait le transport Doctrine. * * @see DUR030 */ @@ -26,6 +35,7 @@ public function __construct( private readonly string $metadataTable = 'durable_workflow_metadata', private readonly string $parentLinkTable = 'durable_child_workflow_parent_link', private readonly string $runsTable = 'durable_workflow_runs', + private readonly bool $autoSetup = true, ) {} /** @@ -33,7 +43,7 @@ public function __construct( */ public function ensure(): void { - if ($this->ensured) { + if (!$this->autoSetup || $this->ensured) { return; } $this->ensured = true; @@ -53,7 +63,34 @@ public function ensure(): void } /** - * Déclare les tables manquantes ; branché aussi sur `configureSchema` côté bundle. + * Ajoute au schéma que Doctrine construit les tables qui manquent, pour que l'outillage les + * connaisse au lieu de les prendre pour des orphelines à supprimer. + * + * Le journal peut vivre sur une autre connexion que celle de l'ORM. Y déclarer ces tables + * ferait créer, dans la base de l'application, des tables qui n'y sont pas — et laisserait + * l'outillage proposer de supprimer, dans la base du journal, celles qui y sont. D'où la + * même garde que les adaptateurs amont : même connexion, ou même base prouvée par la sonde. + * + * @param \Closure(\Closure(string): mixed): bool $isSameDatabase + * + * @return Schema le schéma, complété + */ + public function configureSchema(Schema $schema, Connection $forConnection, \Closure $isSameDatabase): Schema + { + if ($forConnection !== $this->connection && !$isSameDatabase($this->connection->executeStatement(...))) { + return $schema; + } + + $this->addToSchema($schema, array_values(array_filter( + [$this->eventsTable, $this->metadataTable, $this->parentLinkTable, $this->runsTable], + static fn(string $table): bool => $schema->hasTable($table), + ))); + + return $schema; + } + + /** + * Déclare les tables manquantes dans le schéma passé. * * @param list $skip tables déjà présentes */ diff --git a/src/DurableBundle/DependencyInjection/Configuration.php b/src/DurableBundle/DependencyInjection/Configuration.php index 58ed390b..5593d447 100644 --- a/src/DurableBundle/DependencyInjection/Configuration.php +++ b/src/DurableBundle/DependencyInjection/Configuration.php @@ -20,6 +20,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->info("Backend DBAL : exécution durable sur une seule base SQL, sans cluster d'orchestration (DUR030).") ->children() ->scalarNode('connection')->defaultValue('doctrine.dbal.default_connection')->info('Service ID de la Doctrine\\DBAL\\Connection à utiliser')->end() + ->booleanNode('auto_setup')->defaultTrue()->info("Créer les tables manquantes à la première écriture. À passer à false dès que doctrine/migrations tient le schéma : les deux mécanismes écriraient sinon l'un derrière l'autre.")->end() ->scalarNode('lock_factory')->defaultValue('lock.factory')->info("Service ID de la Symfony\\Component\\Lock\\LockFactory qui sérialise les reprises d'une même exécution")->end() ->end() ->end() diff --git a/src/DurableBundle/DependencyInjection/DurableExtension.php b/src/DurableBundle/DependencyInjection/DurableExtension.php index f7aba215..8bfd76d4 100644 --- a/src/DurableBundle/DependencyInjection/DurableExtension.php +++ b/src/DurableBundle/DependencyInjection/DurableExtension.php @@ -5,6 +5,7 @@ namespace Gplanchat\Durable\Bundle\DependencyInjection; use Gplanchat\Bridge\Dbal\Messenger\SingleResumeLockMiddleware; +use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; use Gplanchat\Bridge\Dbal\Schema\DurableSchema; use Gplanchat\Bridge\Dbal\Store\DbalChildWorkflowParentLinkStore; use Gplanchat\Bridge\Dbal\Store\DbalEventStore; @@ -29,6 +30,7 @@ use Gplanchat\Bridge\Temporal\WorkflowServiceClientFactory; use Gplanchat\Durable\Activity\ActivityContractResolver; use Gplanchat\Durable\Activity\NullActivityHeartbeatSender; +use Gplanchat\Durable\Bundle\SchemaListener\DurableSchemaListener; use Gplanchat\Durable\Bundle\CacheWarmer\ActivityContractCacheWarmer; use Gplanchat\Durable\Bundle\Command\DiagnoseExecutionCommand; use Gplanchat\Durable\Bundle\DataCollector\DurableDataCollector; @@ -147,10 +149,22 @@ private function registerDbalStores(ContainerBuilder $container, array $config): $config['workflow_metadata']['table_name'], $config['child_workflow']['parent_link_store']['table_name'], ]) + ->setArgument('$autoSetup', $config['dbal']['auto_setup']) ->setPublic(false) ; $schema = new Reference('durable.dbal.schema'); + // Sans cet écouteur, `doctrine:migrations:diff` ne voit pas les tables du journal et + // génère leur suppression. Enregistré seulement si l'ORM est là : le pont DBAL fonctionne + // sans lui, et une application qui n'a que la DBAL n'a pas de schéma à compléter. + if (class_exists(GenerateSchemaEventArgs::class)) { + $container->register('durable.dbal.schema_listener', DurableSchemaListener::class) + ->setArguments([$schema]) + ->addTag('doctrine.event_listener', ['event' => 'postGenerateSchema']) + ->setPublic(false) + ; + } + if ($eventStoreDbal) { $container->register('durable.event_store.dbal', DbalEventStore::class) ->setArguments([$connection, $schema, $config['event_store']['table_name']]) diff --git a/src/DurableBundle/SchemaListener/DurableSchemaListener.php b/src/DurableBundle/SchemaListener/DurableSchemaListener.php new file mode 100644 index 00000000..922539f9 --- /dev/null +++ b/src/DurableBundle/SchemaListener/DurableSchemaListener.php @@ -0,0 +1,47 @@ +getEntityManager()->getConnection(); + + $this->schema->configureSchema( + $event->getSchema(), + $connection, + // Connexions distinctes : on ne tranche pas, donc on ne déclare pas. Voir le docbloc. + static fn(): bool => false, + ); + } +} diff --git a/src/DurableBundle/composer.json b/src/DurableBundle/composer.json index bfff38ff..ac1149b2 100644 --- a/src/DurableBundle/composer.json +++ b/src/DurableBundle/composer.json @@ -30,7 +30,8 @@ "suggest": { "phpunit/phpunit": "Required by the shipped test helper (Testing\\DurableBundleTestTrait)", "symfony/framework-bundle": "Required by consumers of the shipped test helper (Testing\\DurableBundleTestTrait), whose static::getContainer() resolves against a KernelTestCase", - "symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)" + "symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)", + "doctrine/orm": "Declares the DBAL journal tables to doctrine:schema:update and doctrine:migrations:diff, which would otherwise generate DROP TABLE for them (SchemaListener\\DurableSchemaListener)" }, "autoload": { "psr-4": { diff --git a/tests/unit/Bridge/Dbal/DurableSchemaDeclarationTest.php b/tests/unit/Bridge/Dbal/DurableSchemaDeclarationTest.php new file mode 100644 index 00000000..c04c150d --- /dev/null +++ b/tests/unit/Bridge/Dbal/DurableSchemaDeclarationTest.php @@ -0,0 +1,119 @@ +configureSchema($schema, $connection, static fn(): bool => true); + + foreach (self::TABLES as $table) { + self::assertTrue($schema->hasTable($table), \sprintf('%s doit être déclarée', $table)); + } + } + + /** + * Le schéma que Doctrine construit porte déjà les tables des entités, et peut porter les + * nôtres si une migration précédente les a créées. Redéclarer une table présente lèverait. + */ + public function testUneTableDejaPresenteDansLeSchemaNEstPasRedeclaree(): void + { + $connection = self::connection(); + $schema = new Schema(); + $dejaLa = $schema->createTable('durable_events'); + $dejaLa->addColumn('id', 'bigint'); + + (new DurableSchema($connection))->configureSchema($schema, $connection, static fn(): bool => true); + + self::assertTrue($schema->hasTable('durable_events')); + self::assertTrue($schema->hasTable('durable_workflow_runs'), 'les autres sont déclarées quand même'); + } + + /** + * Le journal peut vivre sur une autre connexion que celle de l'ORM. Y déclarer nos tables + * ferait créer, dans la base de l'application, des tables qui n'y sont pas — et supprimer, + * dans la base du journal, celles qui y sont. + */ + public function testRienNEstDeclareQuandCeNEstPasLaMemeBase(): void + { + $schema = new Schema(); + + (new DurableSchema(self::connection()))->configureSchema( + $schema, + self::connection(), + static fn(): bool => false, + ); + + self::assertSame([], $schema->getTables(), 'aucune table ne doit rejoindre le schéma d\'une autre base'); + } + + public function testLaMemeBaseSurUneAutreConnexionEstDeclaree(): void + { + $schema = new Schema(); + + (new DurableSchema(self::connection()))->configureSchema( + $schema, + self::connection(), + static fn(): bool => true, + ); + + self::assertCount(\count(self::TABLES), $schema->getTables()); + } + + /** + * Quand les migrations tiennent le schéma, le DDL paresseux du pont n'a plus lieu d'être : + * il écrirait derrière le dos de l'outil qui en a désormais la charge. + */ + public function testAutoSetupDesactiveNeCreeAucuneTable(): void + { + $connection = self::connection(); + + (new DurableSchema($connection, autoSetup: false))->ensure(); + + self::assertSame([], $connection->createSchemaManager()->listTableNames()); + } + + public function testAutoSetupActifCreeLesTables(): void + { + $connection = self::connection(); + + (new DurableSchema($connection))->ensure(); + + foreach (self::TABLES as $table) { + self::assertContains($table, $connection->createSchemaManager()->listTableNames()); + } + } + + private static function connection(): \Doctrine\DBAL\Connection + { + return DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + } +} From ea479796a6e32369e3edcd6d91abc544d214bb7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Planchat?= Date: Fri, 4 Sep 2026 01:43:39 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(dbal):=20la=20sonde=20=C2=AB=20m=C3=AAm?= =?UTF-8?q?e=20base=20=C2=BB=20d=C3=A9cide=20vraiment,=20et=20le=20chemin?= =?UTF-8?q?=20de=20production=20est=20test=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La relecture croisée a montré que le correctif se refermait sur lui-même : l'écouteur passait `static fn(): bool => false` à `configureSchema()`, au motif écrit en docbloc que l'API d'`AbstractSchemaListener` aurait bougé entre Symfony ^6.4 et ^8.0. Vérification faite sur `v6.4.0`, `7.2` et `8.0`, `getIsSameDatabaseChecker()` y est déclarée à l'identique ; ce qui a bougé est `filterSchemaChanges()`, l'autre méthode. La justification étant fausse, la prudence qu'elle fondait ne l'était pas : dès que le journal a sa propre connexion — le cas que le README de cette branche décrit lui-même — plus rien n'était déclaré et `doctrine:migrations:diff` regénérait ses `DROP TABLE`. B4 était fermé pour le cas par défaut, rouvert pour son cas d'usage. La sonde est donc recopiée depuis `AbstractSchemaListener`, vingt lignes qui ne dépendent que de la DBAL, plutôt qu'héritée : elle y est `protected`, et l'étendre imposerait `symfony/doctrine-bridge` au bundle. `doctrine/orm` était écrit à la main dans `require-dev`, hors ordre alphabétique malgré `sort-packages`, et jamais résolu : les huit jobs qui font un `composer install` étaient rouges, aucun test n'avait tourné sur cette branche. La moitié `^2.19` de la contrainte était de toute façon insatisfiable ici — ORM 2 veut DBAL `^2.13.1 || ^3.2`, le pont veut `^3.7 || ^4.0` et le lock porte DBAL 4.4.4. Contrainte ramenée à `^3.0`, lock à jour. Le seul chemin de production n'avait aucun test : les cas existants appellent `configureSchema()` en lui passant la sonde, ce qui prouve la forme du schéma et jamais la décision de déclarer. `DurableSchemaListenerTest` exerce `postGenerateSchema()` avec la vraie sonde sur trois cas — même connexion, deux connexions sur le même fichier SQLite, deux bases distinctes. Le deuxième échoue sur le code d'avant ce commit (0 table déclarée au lieu de 4). Le README annonçait une abstention que le code ne pratique plus. Restent ouverts, hors périmètre de cette branche : le contournement de `schema_filter` (l'amont passe par `filterSchemaChanges()`, absente en 6.4), et l'absence de chemin de création supporté quand `auto_setup: false` — un `durable:setup`, à l'image de `messenger:setup-transports`. Co-Authored-By: Claude Opus 5 (1M context) --- composer.json | 2 +- composer.lock | 615 +++++++++++++++++- src/Bridge/Dbal/README.md | 11 +- .../DependencyInjection/DurableExtension.php | 4 +- .../SchemaListener/DurableSchemaListener.php | 48 +- .../DurableSchemaListenerTest.php | 112 ++++ 6 files changed, 763 insertions(+), 29 deletions(-) create mode 100644 tests/unit/DurableBundle/SchemaListener/DurableSchemaListenerTest.php diff --git a/composer.json b/composer.json index 0d22c319..2f932693 100644 --- a/composer.json +++ b/composer.json @@ -57,10 +57,10 @@ "gplanchat/durable-laravel": "@dev" }, "require-dev": { + "doctrine/orm": "^3.0", "friendsofphp/php-cs-fixer": "^3.0", "gplanchat/durable-phpstan": "@dev", "gplanchat/durable-rector": "@dev", - "doctrine/orm": "^2.19 || ^3.0", "illuminate/cache": "^11.0 || ^12.0 || ^13.0", "illuminate/database": "^11.0 || ^12.0 || ^13.0", "phpstan/phpstan": "^2.0", diff --git a/composer.lock b/composer.lock index 7eddd811..bccce676 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4673e00a18c20105a9c3ff4a9640b431", + "content-hash": "d5a30b88c750464826ffb257af16cc68", "packages": [ { "name": "brick/math", @@ -488,7 +488,7 @@ "dist": { "type": "path", "url": "src/Durable", - "reference": "40d593db8ffd3387d387405523a63a57aaf27abe" + "reference": "8be3b6e4e0ef16164efb609d9c0e69aa12968d6c" }, "require": { "php": ">=8.2", @@ -524,6 +524,16 @@ } ], "description": "PHP library for durable execution: workflows, activities, event journal, and transports (HttpKernel integration lives in the bundle).", + "keywords": [ + "activity", + "durable-execution", + "event-sourcing", + "fiber", + "long-running", + "orchestration", + "saga", + "workflow" + ], "transport-options": { "relative": true } @@ -534,7 +544,7 @@ "dist": { "type": "path", "url": "src/Bridge/Dbal", - "reference": "fc0a50ba49b7ef6e27e667e40122ec066af46373" + "reference": "8dbaae5ab6a14fbaef8764f8509467828ee83906" }, "require": { "doctrine/dbal": "^3.7 || ^4.0", @@ -564,6 +574,15 @@ } ], "description": "Doctrine DBAL journal, metadata and parent-link stores for gplanchat/durable — durable execution on a single SQL database, no orchestrator cluster", + "keywords": [ + "doctrine", + "doctrine-dbal", + "durable-execution", + "event-store", + "long-running", + "orchestration", + "workflow" + ], "transport-options": { "relative": true } @@ -574,7 +593,7 @@ "dist": { "type": "path", "url": "src/Bridge/Illuminate", - "reference": "6946428d1fa3a241e1d907d36666a4c35bdec332" + "reference": "0a38d4b04a25bb986f0156c481aeddb373f08c74" }, "require": { "gplanchat/durable": "self.version", @@ -611,6 +630,16 @@ } ], "description": "Illuminate (Laravel) journal, metadata and run stores for gplanchat/durable — durable execution on the connection Laravel already owns", + "keywords": [ + "durable-execution", + "eloquent", + "event-store", + "illuminate", + "laravel", + "long-running", + "orchestration", + "workflow" + ], "transport-options": { "relative": true } @@ -621,7 +650,7 @@ "dist": { "type": "path", "url": "src/Bridge/Temporal", - "reference": "02e08229462d41ad9eab4ccbc5923263ad4fc53a" + "reference": "c9faa1e201c1e56e477c3bfc4fc9e642774ebf09" }, "require": { "ext-grpc": "*", @@ -658,6 +687,16 @@ } ], "description": "Temporal journal EventStore + worker (gRPC only, no Temporal PHP SDK) for gplanchat/durable", + "keywords": [ + "durable-execution", + "grpc", + "long-running", + "nexus", + "orchestration", + "temporal", + "temporalio", + "workflow" + ], "transport-options": { "relative": true } @@ -668,7 +707,7 @@ "dist": { "type": "path", "url": "src/DurableBundle", - "reference": "3c6e1f5bba6af95386df385f68bb4e5f4ab97eec" + "reference": "ca2fbd88a7ad283caaa1454de8c7843f14bcc362" }, "require": { "gplanchat/durable": "self.version", @@ -685,6 +724,7 @@ "symfony/uid": "^6.4 || ^7.0 || ^8.0" }, "suggest": { + "doctrine/orm": "Declares the DBAL journal tables to doctrine:schema:update and doctrine:migrations:diff, which would otherwise generate DROP TABLE for them (SchemaListener\\DurableSchemaListener)", "phpunit/phpunit": "Required by the shipped test helper (Testing\\DurableBundleTestTrait)", "symfony/framework-bundle": "Required by consumers of the shipped test helper (Testing\\DurableBundleTestTrait), whose static::getContainer() resolves against a KernelTestCase", "symfony/web-profiler-bundle": "Web Debug Toolbar and profiler: Durable panel (workflows / activities)" @@ -710,6 +750,16 @@ } ], "description": "Symfony bundle for gplanchat/durable: extension, autoconfiguration of workflows and activities.", + "keywords": [ + "durable-execution", + "long-running", + "messenger", + "orchestration", + "saga", + "symfony", + "symfony-bundle", + "workflow" + ], "transport-options": { "relative": true } @@ -720,7 +770,7 @@ "dist": { "type": "path", "url": "src/DurableLaravel", - "reference": "9302b7f4ee5d308a82a3f5ee094aef5ee3b59941" + "reference": "b92e1877246d6060b5ddd423c8678748a1ed8dbb" }, "require": { "gplanchat/durable": "self.version", @@ -762,6 +812,15 @@ } ], "description": "Laravel integration for gplanchat/durable — binds the four storage ports from one published config file", + "keywords": [ + "durable-execution", + "laravel", + "long-running", + "orchestration", + "queue", + "saga", + "workflow" + ], "transport-options": { "relative": true } @@ -772,7 +831,7 @@ "dist": { "type": "path", "url": "src/DurablePlugin", - "reference": "bd0df46a7647a87434fe7642703c1b42e71827cd" + "reference": "77868a416afd4c6b6ca4952cbbe9e8371e718ed4" }, "require": { "gplanchat/durable": "self.version", @@ -815,6 +874,16 @@ } ], "description": "Sylius-oriented dashboard plugin for durable workflow tracking.", + "keywords": [ + "dashboard", + "durable-execution", + "long-running", + "orchestration", + "sylius", + "sylius-plugin", + "symfony", + "workflow" + ], "transport-options": { "relative": true } @@ -6679,6 +6748,513 @@ }, "time": "2019-12-04T15:06:13+00:00" }, + { + "name": "doctrine/collections", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-json": "*", + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.6.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-01-15T10:01:58+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "doctrine/orm", + "version": "3.6.8", + "source": { + "type": "git", + "url": "https://github.com/doctrine/orm.git", + "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/orm/zipball/a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", + "reference": "a4d13ed5b11e7f7b4d654b1adf95432031ae3ffc", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/collections": "^2.2", + "doctrine/dbal": "^3.8.2 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2", + "doctrine/inflector": "^1.4 || ^2.0", + "doctrine/instantiator": "^1.3 || ^2", + "doctrine/lexer": "^3", + "doctrine/persistence": "^3.3.1 || ^4", + "ext-ctype": "*", + "php": "^8.1", + "psr/cache": "^1 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.3.9 || ^7.0 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0", + "phpbench/phpbench": "^1.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "2.1.23", + "phpstan/phpstan-deprecation-rules": "^2", + "phpunit/phpunit": "^10.5.0 || ^11.5", + "psr/log": "^1 || ^2 || ^3", + "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-deepclone": "Improves performance when not using native lazy objects (Symfony 8.1+)", + "ext-dom": "Provides support for XSD validation for XML mapping files", + "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\ORM\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "Object-Relational-Mapper for PHP", + "homepage": "https://www.doctrine-project.org/projects/orm.html", + "keywords": [ + "database", + "orm" + ], + "support": { + "issues": "https://github.com/doctrine/orm/issues", + "source": "https://github.com/doctrine/orm/tree/3.6.8" + }, + "time": "2026-08-05T19:05:32+00:00" + }, + { + "name": "doctrine/persistence", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/persistence.git", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "doctrine/event-manager": "^1 || ^2", + "php": "^8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Persistence\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://www.doctrine-project.org/projects/persistence.html", + "keywords": [ + "mapper", + "object", + "odm", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/4.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2026-04-26T12:12:52+00:00" + }, { "name": "ergebnis/agent-detector", "version": "1.2.0", @@ -7022,7 +7598,7 @@ "dist": { "type": "path", "url": "src/DurablePhpstan", - "reference": "606b10d331b4b3db47e8708f442766a328889aec" + "reference": "3cdd062794a47c570ff390aa254f4f449f4f3216" }, "require": { "gplanchat/durable": "self.version", @@ -7058,6 +7634,15 @@ } ], "description": "PHPStan extension for gplanchat/durable — resolves ActivityStub and ChildWorkflowStub calls from their typed contract, so a mistyped activity is an analysis error rather than a runtime failure", + "keywords": [ + "durable-execution", + "long-running", + "orchestration", + "phpstan", + "phpstan-extension", + "static-analysis", + "workflow" + ], "transport-options": { "relative": true } @@ -7068,7 +7653,7 @@ "dist": { "type": "path", "url": "src/DurableRector", - "reference": "6be6142045b2da6d7e11605b79b191e1ecaad003" + "reference": "4b3c7519859795a1af7635474a1ee28960b0a15f" }, "require": { "gplanchat/durable": "self.version", @@ -7102,6 +7687,16 @@ } ], "description": "Rector rules that migrate a project off the official Temporal PHP SDK onto gplanchat/durable — attribute rewrites that keep the workflow and activity type names a running server already knows", + "keywords": [ + "durable-execution", + "long-running", + "migration", + "orchestration", + "rector", + "rector-rules", + "temporal", + "workflow" + ], "transport-options": { "relative": true } diff --git a/src/Bridge/Dbal/README.md b/src/Bridge/Dbal/README.md index 8d92a8b1..698d72fb 100644 --- a/src/Bridge/Dbal/README.md +++ b/src/Bridge/Dbal/README.md @@ -89,11 +89,12 @@ dependency. tables belong to the application. Without it they would look like orphans and a generated migration would **drop them** — with the journal, every in-flight execution. -The listener declares the tables only when the journal writes on the very `Connection` the ORM -inspects. Two distinct `Connection` objects can point at the same database, and proving it takes a -probe this bundle does not run; not declaring leaves that schema to you, whereas declaring wrongly -would create tables in the wrong database. Pass your own probe to -`DurableSchema::configureSchema($schema, $connection, $isSameDatabase)` if you need the other case. +The listener declares the tables when the journal writes on the database the ORM inspects — the +same `Connection` object, or a different one proven to reach the same database. Two distinct +`Connection` objects can point at the same database, so the listener runs the probe Symfony's own +`AbstractSchemaListener` uses: it creates a throwaway table on one connection and checks whether the +other can drop it. Declaring on the wrong database would create tables where they do not belong, so +a probe that cannot conclude declares nothing. Once migrations own the schema, turn the lazy creation off — otherwise both mechanisms write behind each other: diff --git a/src/DurableBundle/DependencyInjection/DurableExtension.php b/src/DurableBundle/DependencyInjection/DurableExtension.php index 8bfd76d4..fe7ce46a 100644 --- a/src/DurableBundle/DependencyInjection/DurableExtension.php +++ b/src/DurableBundle/DependencyInjection/DurableExtension.php @@ -4,8 +4,8 @@ namespace Gplanchat\Durable\Bundle\DependencyInjection; -use Gplanchat\Bridge\Dbal\Messenger\SingleResumeLockMiddleware; use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; +use Gplanchat\Bridge\Dbal\Messenger\SingleResumeLockMiddleware; use Gplanchat\Bridge\Dbal\Schema\DurableSchema; use Gplanchat\Bridge\Dbal\Store\DbalChildWorkflowParentLinkStore; use Gplanchat\Bridge\Dbal\Store\DbalEventStore; @@ -30,7 +30,6 @@ use Gplanchat\Bridge\Temporal\WorkflowServiceClientFactory; use Gplanchat\Durable\Activity\ActivityContractResolver; use Gplanchat\Durable\Activity\NullActivityHeartbeatSender; -use Gplanchat\Durable\Bundle\SchemaListener\DurableSchemaListener; use Gplanchat\Durable\Bundle\CacheWarmer\ActivityContractCacheWarmer; use Gplanchat\Durable\Bundle\Command\DiagnoseExecutionCommand; use Gplanchat\Durable\Bundle\DataCollector\DurableDataCollector; @@ -42,6 +41,7 @@ use Gplanchat\Durable\Bundle\Messenger\MessengerWorkflowResumeDispatcher; use Gplanchat\Durable\Bundle\Messenger\WorkflowRunDispatchProfilerMiddleware; use Gplanchat\Durable\Bundle\Profiler\DurableExecutionTrace; +use Gplanchat\Durable\Bundle\SchemaListener\DurableSchemaListener; use Gplanchat\Durable\Bundle\Transport\MessengerActivityTransport; use Gplanchat\Durable\Bundle\Transport\MessengerWorkflowTimerDispatcher; use Gplanchat\Durable\Debug\WorkflowExecutionObserverInterface; diff --git a/src/DurableBundle/SchemaListener/DurableSchemaListener.php b/src/DurableBundle/SchemaListener/DurableSchemaListener.php index 922539f9..0945d67c 100644 --- a/src/DurableBundle/SchemaListener/DurableSchemaListener.php +++ b/src/DurableBundle/SchemaListener/DurableSchemaListener.php @@ -4,6 +4,8 @@ namespace Gplanchat\Durable\Bundle\SchemaListener; +use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Exception\TableNotFoundException; use Doctrine\ORM\Tools\Event\GenerateSchemaEventArgs; use Gplanchat\Bridge\Dbal\Schema\DurableSchema; @@ -17,15 +19,6 @@ * * Le pendant amont est `MessengerTransportDoctrineSchemaListener`, qui existe pour la même raison * et à propos des mêmes tables gérées par une bibliothèque plutôt que par une entité. - * - * **Prudence délibérée sur la connexion.** Les tables ne sont déclarées que si le journal écrit sur - * la connexion même que l'ORM inspecte. Deux objets `Connection` distincts peuvent pointer la même - * base, et l'amont le prouve par une sonde ; cette sonde vit dans `AbstractSchemaListener`, dont - * l'API a bougé à l'intérieur de la plage de versions que ce bundle accepte (^6.4 à ^8.0). Plutôt - * que de s'y adosser au risque d'un appel fatal sur la version basse, on s'abstient : ne rien - * déclarer laisse l'exploitant gérer ce schéma lui-même, là où déclarer à tort ferait créer des - * tables dans la mauvaise base. {@see DurableSchema::configureSchema()} garde le paramètre de - * sonde, et un hôte qui sait mieux peut le fournir. */ final class DurableSchemaListener { @@ -40,8 +33,41 @@ public function postGenerateSchema(GenerateSchemaEventArgs $event): void $this->schema->configureSchema( $event->getSchema(), $connection, - // Connexions distinctes : on ne tranche pas, donc on ne déclare pas. Voir le docbloc. - static fn(): bool => false, + self::isSameDatabase($connection), ); } + + /** + * Sonde « même base » : deux objets `Connection` distincts peuvent pointer la même base, et + * seule une écriture le prouve. Le principe est celui de + * `Symfony\Bridge\Doctrine\SchemaListener\AbstractSchemaListener::getIsSameDatabaseChecker()`, + * dont la déclaration est identique de Symfony 6.4 à 8.0 — vérifié sur `v6.4.0`, `7.2` et + * `8.0`. Elle est recopiée plutôt qu'héritée pour deux raisons : elle y est `protected`, donc + * inaccessible sans étendre la classe, et l'étendre imposerait `symfony/doctrine-bridge` au + * bundle pour vingt lignes qui ne dépendent que de la DBAL. + * + * @return \Closure(\Closure(string): mixed): bool + */ + private static function isSameDatabase(Connection $connection): \Closure + { + return static function (\Closure $exec) use ($connection): bool { + $checkTable = 'durable_schema_check_' . bin2hex(random_bytes(7)); + $connection->executeStatement(\sprintf('CREATE TABLE %s (id INTEGER NOT NULL)', $checkTable)); + + try { + $exec(\sprintf('DROP TABLE %s', $checkTable)); + } catch (\Exception) { + // La connexion du journal n'a pas pu supprimer la table : soit une autre base, + // soit un droit manquant. Le second contrôle tranche. + } + + try { + $connection->executeStatement(\sprintf('DROP TABLE %s', $checkTable)); + + return false; + } catch (TableNotFoundException) { + return true; + } + }; + } } diff --git a/tests/unit/DurableBundle/SchemaListener/DurableSchemaListenerTest.php b/tests/unit/DurableBundle/SchemaListener/DurableSchemaListenerTest.php new file mode 100644 index 00000000..b1749c29 --- /dev/null +++ b/tests/unit/DurableBundle/SchemaListener/DurableSchemaListenerTest.php @@ -0,0 +1,112 @@ + */ + private array $fichiers = []; + + protected function tearDown(): void + { + foreach ($this->fichiers as $fichier) { + @unlink($fichier); + } + $this->fichiers = []; + } + + public function testLaMemeConnexionDeclareLesTables(): void + { + $connection = self::enMemoire(); + $schema = new Schema(); + + (new DurableSchemaListener(new DurableSchema($connection))) + ->postGenerateSchema($this->evenement($connection, $schema)); + + foreach (self::TABLES as $table) { + self::assertTrue($schema->hasTable($table), \sprintf('%s doit être déclarée', $table)); + } + } + + /** + * Deux objets `Connection` distincts sur le même fichier : c'est le cas que la sonde existe + * pour trancher, et celui qu'une sonde câblée sur `false` traitait comme une autre base. + */ + public function testDeuxConnexionsSurLaMemeBaseDeclarentLesTables(): void + { + $fichier = $this->fichier(); + $journal = self::surFichier($fichier); + $orm = self::surFichier($fichier); + $schema = new Schema(); + + (new DurableSchemaListener(new DurableSchema($journal))) + ->postGenerateSchema($this->evenement($orm, $schema)); + + self::assertCount(\count(self::TABLES), $schema->getTables(), 'la sonde doit reconnaître la même base'); + } + + public function testDeuxBasesDistinctesNeDeclarentRien(): void + { + $journal = self::surFichier($this->fichier()); + $orm = self::surFichier($this->fichier()); + $schema = new Schema(); + + (new DurableSchemaListener(new DurableSchema($journal))) + ->postGenerateSchema($this->evenement($orm, $schema)); + + self::assertSame([], $schema->getTables(), 'aucune table ne doit rejoindre le schéma d\'une autre base'); + } + + private function evenement(Connection $connection, Schema $schema): GenerateSchemaEventArgs + { + $em = $this->createMock(EntityManagerInterface::class); + $em->method('getConnection')->willReturn($connection); + + return new GenerateSchemaEventArgs($em, $schema); + } + + private static function enMemoire(): Connection + { + return DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]); + } + + private static function surFichier(string $chemin): Connection + { + return DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $chemin]); + } + + private function fichier(): string + { + $chemin = \sprintf('%s/durable-schema-%s.sqlite', sys_get_temp_dir(), bin2hex(random_bytes(6))); + $this->fichiers[] = $chemin; + + return $chemin; + } +}