From df568da60995a45afde0f77bd5b85c1a5b89e3d9 Mon Sep 17 00:00:00 2001 From: Nicolas PHILIPPE Date: Wed, 23 Oct 2024 10:37:02 +0200 Subject: [PATCH] refactor: make "database reset" mechanism extendable (#690) --- bin/console | 4 +- config/mongo.php | 6 + config/orm.php | 37 +++++ config/persistence.php | 7 + phpstan-baseline.neon | 151 ------------------ src/Configuration.php | 3 + src/Mongo/MongoPersistenceStrategy.php | 39 ----- src/Mongo/MongoResetter.php | 11 ++ src/Mongo/MongoSchemaResetter.php | 38 +++++ src/ORM/AbstractORMPersistenceStrategy.php | 125 +-------------- src/ORM/ResetDatabase/BaseOrmResetter.php | 67 ++++++++ .../ResetDatabase/DamaDatabaseResetter.php | 61 +++++++ .../ResetDatabase/MigrateDatabaseResetter.php | 55 +++++++ src/ORM/ResetDatabase/OrmResetter.php | 15 ++ src/ORM/ResetDatabase/ResetDatabaseMode.php | 14 ++ .../ResetDatabase/SchemaDatabaseResetter.php | 53 ++++++ src/Persistence/PersistenceManager.php | 131 +++------------ src/Persistence/PersistenceStrategy.php | 32 ---- .../ResetDatabase/BeforeEachTestResetter.php | 16 ++ .../ResetDatabase/BeforeFirstTestResetter.php | 16 ++ .../ResetDatabase/ResetDatabaseManager.php | 108 +++++++++++++ src/Persistence/SymfonyCommandRunner.php | 40 +++++ src/Test/ResetDatabase.php | 9 +- src/ZenstruckFoundryBundle.php | 36 ++++- tests/Fixture/TestKernel.php | 5 +- tests/bootstrap.php | 4 +- 26 files changed, 617 insertions(+), 466 deletions(-) delete mode 100644 phpstan-baseline.neon create mode 100644 src/Mongo/MongoResetter.php create mode 100644 src/Mongo/MongoSchemaResetter.php create mode 100644 src/ORM/ResetDatabase/BaseOrmResetter.php create mode 100644 src/ORM/ResetDatabase/DamaDatabaseResetter.php create mode 100644 src/ORM/ResetDatabase/MigrateDatabaseResetter.php create mode 100644 src/ORM/ResetDatabase/OrmResetter.php create mode 100644 src/ORM/ResetDatabase/ResetDatabaseMode.php create mode 100644 src/ORM/ResetDatabase/SchemaDatabaseResetter.php create mode 100644 src/Persistence/ResetDatabase/BeforeEachTestResetter.php create mode 100644 src/Persistence/ResetDatabase/BeforeFirstTestResetter.php create mode 100644 src/Persistence/ResetDatabase/ResetDatabaseManager.php create mode 100644 src/Persistence/SymfonyCommandRunner.php diff --git a/bin/console b/bin/console index 5944a814f..9ff51b3b3 100755 --- a/bin/console +++ b/bin/console @@ -2,9 +2,9 @@ run(); diff --git a/config/mongo.php b/config/mongo.php index 048cde99a..83ae902fd 100644 --- a/config/mongo.php +++ b/config/mongo.php @@ -3,6 +3,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Zenstruck\Foundry\Mongo\MongoPersistenceStrategy; +use Zenstruck\Foundry\Mongo\MongoSchemaResetter; return static function (ContainerConfigurator $container): void { $container->services() @@ -12,5 +13,10 @@ abstract_arg('config'), ]) ->tag('.foundry.persistence_strategy') + ->set('.zenstruck_foundry.persistence.schema_resetter.mongo', MongoSchemaResetter::class) + ->args([ + abstract_arg('managers'), + ]) + ->tag('.foundry.persistence.schema_resetter') ; }; diff --git a/config/orm.php b/config/orm.php index 203b92275..1621dc5a8 100644 --- a/config/orm.php +++ b/config/orm.php @@ -2,9 +2,14 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; use Zenstruck\Foundry\ORM\DoctrineOrmVersionGuesser; use Zenstruck\Foundry\ORM\OrmV2PersistenceStrategy; use Zenstruck\Foundry\ORM\OrmV3PersistenceStrategy; +use Zenstruck\Foundry\ORM\ResetDatabase\BaseOrmResetter; +use Zenstruck\Foundry\ORM\ResetDatabase\DamaDatabaseResetter; +use Zenstruck\Foundry\ORM\ResetDatabase\SchemaDatabaseResetter; +use Zenstruck\Foundry\ORM\ResetDatabase\MigrateDatabaseResetter; return static function (ContainerConfigurator $container): void { $container->services() @@ -14,5 +19,37 @@ abstract_arg('config'), ]) ->tag('.foundry.persistence_strategy') + + ->set('.zenstruck_foundry.persistence.database_resetter.orm.abstract', BaseOrmResetter::class) + ->arg('$registry', service('doctrine')) + ->arg('$managers', service('managers')) + ->arg('$connections', service('connections')) + ->abstract() + + ->set('.zenstruck_foundry.persistence.database_resetter.orm.schema', SchemaDatabaseResetter::class) + ->parent('.zenstruck_foundry.persistence.database_resetter.orm.abstract') + ->tag('.foundry.persistence.database_resetter') + ->tag('.foundry.persistence.schema_resetter') + + ->set('.zenstruck_foundry.persistence.database_resetter.orm.migrate', MigrateDatabaseResetter::class) + ->arg('$configurations', abstract_arg('configurations')) + ->parent('.zenstruck_foundry.persistence.database_resetter.orm.abstract') + ->tag('.foundry.persistence.database_resetter') + ->tag('.foundry.persistence.schema_resetter') ; + + if (\class_exists(StaticDriver::class)) { + $container->services() + ->set('.zenstruck_foundry.persistence.database_resetter.orm.schema.dama', DamaDatabaseResetter::class) + ->decorate('.zenstruck_foundry.persistence.database_resetter.orm.schema') + ->args([ + service('.inner'), + ]) + ->set('.zenstruck_foundry.persistence.database_resetter.orm.migrate.dama', DamaDatabaseResetter::class) + ->decorate('.zenstruck_foundry.persistence.database_resetter.orm.migrate') + ->args([ + service('.inner'), + ]) + ; + } }; diff --git a/config/persistence.php b/config/persistence.php index 974e76a60..69d4ccaa4 100644 --- a/config/persistence.php +++ b/config/persistence.php @@ -3,12 +3,19 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; return static function (ContainerConfigurator $container): void { $container->services() ->set('.zenstruck_foundry.persistence_manager', PersistenceManager::class) ->args([ tagged_iterator('.foundry.persistence_strategy'), + service('.zenstruck_foundry.persistence.reset_database_manager'), + ]) + ->set('.zenstruck_foundry.persistence.reset_database_manager', ResetDatabaseManager::class) + ->args([ + tagged_iterator('.foundry.persistence.database_resetter'), + tagged_iterator('.foundry.persistence.schema_resetter'), ]) ; }; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 81436056c..000000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,151 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^Should not use node with type \"Expr_Eval\", please change the code\\.$#" - count: 1 - path: src/AnonymousFactoryGenerator.php - - - - message: "#^Method Zenstruck\\\\Foundry\\\\Configuration\\:\\:repositoryFor\\(\\) should return Zenstruck\\\\Foundry\\\\Persistence\\\\RepositoryDecorator\\ but returns Zenstruck\\\\Foundry\\\\Persistence\\\\ProxyRepositoryDecorator\\\\|Zenstruck\\\\Foundry\\\\Persistence\\\\RepositoryDecorator\\\\.$#" - count: 1 - path: src/Configuration.php - - - - message: "#^Parameter \\#1 \\$class of method Doctrine\\\\Persistence\\\\ManagerRegistry\\:\\:getManagerForClass\\(\\) expects class\\-string, string given\\.$#" - count: 1 - path: src/Configuration.php - - - - message: "#^Method Zenstruck\\\\Foundry\\\\Factory\\:\\:createAndUproxify\\(\\) should return TObject of object but returns object\\.$#" - count: 1 - path: src/Factory.php - - - - message: "#^Should not use function \"debug_backtrace\", please change the code\\.$#" - count: 2 - path: src/Factory.php - - - - message: "#^Method Zenstruck\\\\Foundry\\\\FactoryCollection\\:\\:create\\(\\) should return array\\ but returns array\\, \\(TObject of object\\)\\|Zenstruck\\\\Foundry\\\\Persistence\\\\Proxy\\\\>\\.$#" - count: 1 - path: src/FactoryCollection.php - - - - message: "#^Parameter \\#1 \\$min of function random_int expects int, int\\|null given\\.$#" - count: 1 - path: src/FactoryCollection.php - - - - message: "#^Parameter \\#2 \\$max of function random_int expects int, int\\|null given\\.$#" - count: 1 - path: src/FactoryCollection.php - - - - message: "#^Should not use function \"debug_backtrace\", please change the code\\.$#" - count: 1 - path: src/FactoryCollection.php - - - - message: "#^Class Zenstruck\\\\Foundry\\\\Instantiator extends @final class Zenstruck\\\\Foundry\\\\Object\\\\Instantiator\\.$#" - count: 1 - path: src/Instantiator.php - - - - message: "#^If condition is always false\\.$#" - count: 1 - path: src/Instantiator.php - - - - message: "#^Method Zenstruck\\\\Foundry\\\\ObjectFactory\\:\\:__callStatic\\(\\) should return array\\ but returns array\\\\>\\.$#" - count: 1 - path: src/ObjectFactory.php - - - - message: "#^Method Zenstruck\\\\Foundry\\\\ObjectFactory\\:\\:createSequence\\(\\) should return array\\ but returns array\\\\>\\.$#" - count: 1 - path: src/ObjectFactory.php - - - - message: "#^Should not use function \"debug_backtrace\", please change the code\\.$#" - count: 1 - path: src/ObjectFactory.php - - - - message: "#^Unsafe usage of new static\\(\\)\\.$#" - count: 1 - path: src/ObjectFactory.php - - - - message: "#^Should not use function \"debug_backtrace\", please change the code\\.$#" - count: 1 - path: src/Persistence/PersistentProxyObjectFactory.php - - - - message: "#^Class Zenstruck\\\\Foundry\\\\Persistence\\\\ProxyRepositoryDecorator extends @final class Zenstruck\\\\Foundry\\\\Persistence\\\\RepositoryDecorator\\.$#" - count: 1 - path: src/Persistence/ProxyRepositoryDecorator.php - - - - message: "#^Method Zenstruck\\\\Foundry\\\\Persistence\\\\ProxyRepositoryDecorator\\:\\:proxyResult\\(\\) should return array\\\\>\\|Zenstruck\\\\Foundry\\\\Persistence\\\\Proxy\\ but returns \\(TProxiedObject of object\\)\\|null\\.$#" - count: 1 - path: src/Persistence/ProxyRepositoryDecorator.php - - - - message: "#^Method Zenstruck\\\\Foundry\\\\Persistence\\\\ProxyRepositoryDecorator\\:\\:proxyResult\\(\\) should return array\\\\>\\|Zenstruck\\\\Foundry\\\\Persistence\\\\Proxy\\ but returns Zenstruck\\\\Foundry\\\\Proxy\\.$#" - count: 1 - path: src/Persistence/ProxyRepositoryDecorator.php - - - - message: "#^PHPDoc tag @return with type Zenstruck\\\\Foundry\\\\Persistence\\\\Proxy\\ is not subtype of native type Zenstruck\\\\Foundry\\\\Proxy\\.$#" - count: 1 - path: src/Proxy.php - - - - message: "#^Class Zenstruck\\\\Foundry\\\\RepositoryAssertions extends @final class Zenstruck\\\\Foundry\\\\Persistence\\\\RepositoryAssertions\\.$#" - count: 1 - path: src/RepositoryAssertions.php - - - - message: "#^If condition is always false\\.$#" - count: 1 - path: src/RepositoryAssertions.php - - - - message: "#^Class Zenstruck\\\\Foundry\\\\RepositoryProxy extends @final class Zenstruck\\\\Foundry\\\\Persistence\\\\ProxyRepositoryDecorator\\.$#" - count: 1 - path: src/RepositoryProxy.php - - - - message: "#^If condition is always false\\.$#" - count: 1 - path: src/RepositoryProxy.php - - - - message: "#^Method Zenstruck\\\\Foundry\\\\Test\\\\DatabaseResetter\\:\\:getConfiguration\\(\\) should return Zenstruck\\\\Foundry\\\\Configuration\\|null but returns object\\|null\\.$#" - count: 1 - path: src/Test/DatabaseResetter.php - - - - message: "#^Parameter \\#1 \\$globalStateRegistry of static method Zenstruck\\\\Foundry\\\\Test\\\\TestState\\:\\:flushGlobalState\\(\\) expects Zenstruck\\\\Foundry\\\\Test\\\\GlobalStateRegistry\\|null, object\\|null given\\.$#" - count: 1 - path: src/Test/DatabaseResetter.php - - - - message: "#^Parameter \\#2 \\$registry of class Zenstruck\\\\Foundry\\\\Test\\\\ODMSchemaResetter constructor expects Doctrine\\\\Persistence\\\\ManagerRegistry, object\\|null given\\.$#" - count: 1 - path: src/Test/DatabaseResetter.php - - - - message: "#^Parameter \\#2 \\$registry of class Zenstruck\\\\Foundry\\\\Test\\\\ORMDatabaseResetter constructor expects Doctrine\\\\Persistence\\\\ManagerRegistry, object\\|null given\\.$#" - count: 1 - path: src/Test/DatabaseResetter.php - - - - message: "#^Call to an undefined method object\\:\\:getDatabasePlatform\\(\\)\\.$#" - count: 1 - path: src/Test/ORMDatabaseResetter.php - - - - message: "#^Parameter \\#1 \\$configuration of static method Zenstruck\\\\Foundry\\\\Factory\\\\:\\:boot\\(\\) expects Zenstruck\\\\Foundry\\\\Configuration, object\\|null given\\.$#" - count: 1 - path: src/ZenstruckFoundryBundle.php diff --git a/src/Configuration.php b/src/Configuration.php index b880689e1..c59a4a7ac 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -49,6 +49,9 @@ public function __construct( $this->instantiator = $instantiator; } + /** + * @throws PersistenceNotAvailable + */ public function persistence(): PersistenceManager { return $this->persistence ?? throw new PersistenceNotAvailable('No persistence managers configured. Note: persistence cannot be used in unit tests.'); diff --git a/src/Mongo/MongoPersistenceStrategy.php b/src/Mongo/MongoPersistenceStrategy.php index 0096b8398..e374549f9 100644 --- a/src/Mongo/MongoPersistenceStrategy.php +++ b/src/Mongo/MongoPersistenceStrategy.php @@ -89,43 +89,4 @@ public function isEmbeddable(object $object): bool { return $this->objectManagerFor($object::class)->getClassMetadata($object::class)->isEmbeddedDocument; } - - public function resetDatabase(KernelInterface $kernel): void - { - // noop - } - - public function resetSchema(KernelInterface $kernel): void - { - $application = self::application($kernel); - - foreach ($this->managers() as $manager) { - try { - self::runCommand( - $application, - 'doctrine:mongodb:schema:drop', - [ - '--dm' => $manager, - ] - ); - } catch (\Exception) { - } - - self::runCommand( - $application, - 'doctrine:mongodb:schema:create', - [ - '--dm' => $manager, - ] - ); - } - } - - /** - * @return string[] - */ - private function managers(): array - { - return $this->config['reset']['document_managers']; - } } diff --git a/src/Mongo/MongoResetter.php b/src/Mongo/MongoResetter.php new file mode 100644 index 000000000..f168220cf --- /dev/null +++ b/src/Mongo/MongoResetter.php @@ -0,0 +1,11 @@ + + */ +final class MongoSchemaResetter implements MongoResetter +{ + use SymfonyCommandRunner; + + /** + * @param list $managers + */ + public function __construct(private array $managers) + { + } + + public function resetBeforeEachTest(KernelInterface $kernel): void + { + $application = self::application($kernel); + + foreach ($this->managers as $manager) { + try { + self::runCommand($application, 'doctrine:mongodb:schema:drop', ['--dm' => $manager]); + } catch (\Exception) { + } + + self::runCommand($application, 'doctrine:mongodb:schema:create', ['--dm' => $manager]); + } + } +} diff --git a/src/ORM/AbstractORMPersistenceStrategy.php b/src/ORM/AbstractORMPersistenceStrategy.php index 3591b010a..36f14f8da 100644 --- a/src/ORM/AbstractORMPersistenceStrategy.php +++ b/src/ORM/AbstractORMPersistenceStrategy.php @@ -11,14 +11,9 @@ namespace Zenstruck\Foundry\ORM; -use Doctrine\DBAL\Platforms\PostgreSQLPlatform; -use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\MappingException as ORMMappingException; use Doctrine\Persistence\Mapping\MappingException; -use Symfony\Bundle\FrameworkBundle\Console\Application; -use Symfony\Component\HttpKernel\KernelInterface; -use Zenstruck\Foundry\Persistence\PersistenceManager; use Zenstruck\Foundry\Persistence\PersistenceStrategy; /** @@ -31,9 +26,6 @@ */ abstract class AbstractORMPersistenceStrategy extends PersistenceStrategy { - public const RESET_MODE_SCHEMA = 'schema'; - public const RESET_MODE_MIGRATE = 'migrate'; - final public function contains(object $object): bool { $em = $this->objectManagerFor($object::class); @@ -52,7 +44,7 @@ final public function hasChanges(object $object): bool // cannot use UOW::recomputeSingleEntityChangeSet() here as it wrongly computes embedded objects as changed $em->getUnitOfWork()->computeChangeSet($em->getClassMetadata($object::class), $object); - return (bool) $em->getUnitOfWork()->getEntityChangeSet($object); + return (bool)$em->getUnitOfWork()->getEntityChangeSet($object); } final public function truncate(string $class): void @@ -86,27 +78,6 @@ final public function isEmbeddable(object $object): bool return $this->objectManagerFor($object::class)->getClassMetadata($object::class)->isEmbeddedClass; } - final public function resetDatabase(KernelInterface $kernel): void - { - $application = self::application($kernel); - - $this->dropAndResetDatabase($application); - $this->createSchema($application); - } - - final public function resetSchema(KernelInterface $kernel): void - { - if (PersistenceManager::isDAMADoctrineTestBundleEnabled()) { - // not required as the DAMADoctrineTestBundle wraps each test in a transaction - return; - } - - $application = self::application($kernel); - - $this->dropSchema($application); - $this->createSchema($application); - } - final public function managedNamespaces(): array { $namespaces = []; @@ -117,98 +88,4 @@ final public function managedNamespaces(): array return \array_values(\array_merge(...$namespaces)); } - - private function dropAndResetDatabase(Application $application): void - { - foreach ($this->connections() as $connection) { - $databasePlatform = $this->registry->getConnection($connection)->getDatabasePlatform(); // @phpstan-ignore method.notFound - - if ($databasePlatform instanceof SQLitePlatform) { - // we don't need to create the sqlite database - it's created when the schema is created - continue; - } - - if ($databasePlatform instanceof PostgreSQLPlatform) { - // let's drop all connections to the database to be able to drop it - self::runCommand( - $application, - 'dbal:run-sql', - [ - '--connection' => $connection, - 'sql' => 'SELECT pid, pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid()', - ], - canFail: true, - ); - } - - self::runCommand($application, 'doctrine:database:drop', [ - '--connection' => $connection, - '--force' => true, - '--if-exists' => true, - ]); - self::runCommand($application, 'doctrine:database:create', ['--connection' => $connection]); - } - } - - private function createSchema(Application $application): void - { - if (self::RESET_MODE_SCHEMA === $this->config['reset']['mode']) { - foreach ($this->managers() as $manager) { - self::runCommand($application, 'doctrine:schema:update', [ - '--em' => $manager, - '--force' => true, - ]); - } - - return; - } - - if (!$migrationsConfigurations = $this->config['reset']['migrations']['configurations']) { - self::runCommand($application, 'doctrine:migrations:migrate', [ - '--no-interaction' => true, - ]); - - return; - } - - foreach ($migrationsConfigurations as $migrationsConfiguration) { - self::runCommand($application, 'doctrine:migrations:migrate', [ - '--configuration' => $migrationsConfiguration, - '--no-interaction' => true, - ]); - } - } - - private function dropSchema(Application $application): void - { - if (self::RESET_MODE_MIGRATE === $this->config['reset']['mode']) { - $this->dropAndResetDatabase($application); - - return; - } - - foreach ($this->managers() as $manager) { - self::runCommand($application, 'doctrine:schema:drop', [ - '--em' => $manager, - '--force' => true, - '--full-database' => true, - ]); - } - } - - /** - * @return string[] - */ - private function managers(): array - { - return $this->config['reset']['entity_managers']; - } - - /** - * @return string[] - */ - private function connections(): array - { - return $this->config['reset']['connections']; - } } diff --git a/src/ORM/ResetDatabase/BaseOrmResetter.php b/src/ORM/ResetDatabase/BaseOrmResetter.php new file mode 100644 index 000000000..4044fe281 --- /dev/null +++ b/src/ORM/ResetDatabase/BaseOrmResetter.php @@ -0,0 +1,67 @@ + + * @internal + */ +abstract class BaseOrmResetter +{ + use SymfonyCommandRunner; + + /** + * @param list $managers + * @param list $connections + */ + public function __construct( + private readonly Registry $registry, + protected readonly array $managers, + protected readonly array $connections, + ) { + } + + final protected function dropAndResetDatabase(Application $application): void + { + foreach ($this->connections as $connectionName) { + /** @var Connection $connection */ + $connection = $this->registry->getConnection($connectionName); + $databasePlatform = $connection->getDatabasePlatform(); + + if ($databasePlatform instanceof SQLitePlatform) { + // we don't need to create the sqlite database - it's created when the schema is created + continue; + } + + if ($databasePlatform instanceof PostgreSQLPlatform) { + // let's drop all connections to the database to be able to drop it + self::runCommand( + $application, + 'dbal:run-sql', + [ + '--connection' => $connectionName, + 'sql' => 'SELECT pid, pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid()', + ], + canFail: true, + ); + } + + self::runCommand( + $application, + 'doctrine:database:drop', + ['--connection' => $connectionName, '--force' => true, '--if-exists' => true] + ); + + self::runCommand($application, 'doctrine:database:create', ['--connection' => $connectionName]); + } + } +} diff --git a/src/ORM/ResetDatabase/DamaDatabaseResetter.php b/src/ORM/ResetDatabase/DamaDatabaseResetter.php new file mode 100644 index 000000000..1d0956ba7 --- /dev/null +++ b/src/ORM/ResetDatabase/DamaDatabaseResetter.php @@ -0,0 +1,61 @@ + + */ +final class DamaDatabaseResetter implements OrmResetter +{ + public function __construct( + private OrmResetter $decorated, + ) { + } + + public function resetBeforeFirstTest(KernelInterface $kernel): void + { + $isDAMADoctrineTestBundleEnabled = ResetDatabaseManager::isDAMADoctrineTestBundleEnabled(); + + if (!$isDAMADoctrineTestBundleEnabled) { + $this->decorated->resetBeforeFirstTest($kernel); + + return; + } + + // disable static connections for this operation + StaticDriver::setKeepStaticConnections(false); + + $this->decorated->resetBeforeFirstTest($kernel); + + if (PersistenceManager::isOrmOnly()) { + // add global stories so they are available after transaction rollback + Configuration::instance()->stories->loadGlobalStories(); + } + + // shutdown kernel before re-enabling static connections + // this would prevent any error if any ResetInterface execute sql queries (example: symfony/doctrine-messenger) + $kernel->shutdown(); + + // re-enable static connections + StaticDriver::setKeepStaticConnections(true); + } + + public function resetBeforeEachTest(KernelInterface $kernel): void + { + if (ResetDatabaseManager::isDAMADoctrineTestBundleEnabled()) { + // not required as the DAMADoctrineTestBundle wraps each test in a transaction + return; + } + + $this->decorated->resetBeforeEachTest($kernel); + } +} diff --git a/src/ORM/ResetDatabase/MigrateDatabaseResetter.php b/src/ORM/ResetDatabase/MigrateDatabaseResetter.php new file mode 100644 index 000000000..fd99b57b2 --- /dev/null +++ b/src/ORM/ResetDatabase/MigrateDatabaseResetter.php @@ -0,0 +1,55 @@ + + */ +final class MigrateDatabaseResetter extends BaseOrmResetter implements OrmResetter +{ + /** + * @param list $configurations + */ + public function __construct( + private readonly array $configurations, + Registry $registry, + array $managers, + array $connections, + ) + { + parent::__construct($registry, $managers, $connections); + } + + final public function resetBeforeEachTest(KernelInterface $kernel): void + { + $this->resetWithMigration($kernel); + } + + public function resetBeforeFirstTest(KernelInterface $kernel): void + { + $this->resetWithMigration($kernel); + } + + private function resetWithMigration(KernelInterface $kernel): void + { + $application = self::application($kernel); + + $this->dropAndResetDatabase($application); + + if (!$this->configurations) { + self::runCommand($application, 'doctrine:migrations:migrate'); + + return; + } + + foreach ($this->configurations as $configuration) { + self::runCommand($application, 'doctrine:migrations:migrate', ['--configuration' => $configuration]); + } + } +} diff --git a/src/ORM/ResetDatabase/OrmResetter.php b/src/ORM/ResetDatabase/OrmResetter.php new file mode 100644 index 000000000..3ce4c8228 --- /dev/null +++ b/src/ORM/ResetDatabase/OrmResetter.php @@ -0,0 +1,15 @@ + + */ +interface OrmResetter extends BeforeFirstTestResetter, BeforeEachTestResetter +{ +} diff --git a/src/ORM/ResetDatabase/ResetDatabaseMode.php b/src/ORM/ResetDatabase/ResetDatabaseMode.php new file mode 100644 index 000000000..bd10ef1b1 --- /dev/null +++ b/src/ORM/ResetDatabase/ResetDatabaseMode.php @@ -0,0 +1,14 @@ + + */ +enum ResetDatabaseMode: string +{ + case SCHEMA = 'schema'; + case MIGRATE = 'migrate'; +} diff --git a/src/ORM/ResetDatabase/SchemaDatabaseResetter.php b/src/ORM/ResetDatabase/SchemaDatabaseResetter.php new file mode 100644 index 000000000..b21d54dc5 --- /dev/null +++ b/src/ORM/ResetDatabase/SchemaDatabaseResetter.php @@ -0,0 +1,53 @@ + + */ +final class SchemaDatabaseResetter extends BaseOrmResetter implements OrmResetter +{ + final public function resetBeforeFirstTest(KernelInterface $kernel): void + { + $application = self::application($kernel); + + $this->dropAndResetDatabase($application); + $this->createSchema($application); + } + + final public function resetBeforeEachTest(KernelInterface $kernel): void + { + $application = self::application($kernel); + + $this->dropSchema($application); + $this->createSchema($application); + } + + private function createSchema(Application $application): void + { + foreach ($this->managers as $manager) { + self::runCommand( + $application, + 'doctrine:schema:update', + ['--em' => $manager, '--force' => true] + ); + } + } + + private function dropSchema(Application $application): void + { + foreach ($this->managers as $manager) { + self::runCommand( + $application, + 'doctrine:schema:drop', + ['--em' => $manager, '--force' => true, '--full-database' => true] + ); + } + } +} diff --git a/src/Persistence/PersistenceManager.php b/src/Persistence/PersistenceManager.php index 324089ff0..b51af0e0f 100644 --- a/src/Persistence/PersistenceManager.php +++ b/src/Persistence/PersistenceManager.php @@ -11,17 +11,15 @@ namespace Zenstruck\Foundry\Persistence; -use DAMA\DoctrineTestBundle\Doctrine\DBAL\StaticDriver; use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectRepository; -use Symfony\Component\HttpKernel\KernelInterface; use Zenstruck\Foundry\Configuration; use Zenstruck\Foundry\Exception\PersistenceNotAvailable; use Zenstruck\Foundry\ORM\AbstractORMPersistenceStrategy; use Zenstruck\Foundry\Persistence\Exception\NoPersistenceStrategy; use Zenstruck\Foundry\Persistence\Exception\RefreshObjectFailed; -use Zenstruck\Foundry\Tests\Fixture\TestKernel; +use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; /** * @author Kevin Bond @@ -30,112 +28,16 @@ */ final class PersistenceManager { - private static bool $hasDatabaseBeenReset = false; - private static bool $ormOnly = false; - private bool $flush = true; private bool $persist = true; /** - * @param PersistenceStrategy[] $strategies - */ - public function __construct(private iterable $strategies) - { - } - - public static function isDAMADoctrineTestBundleEnabled(): bool - { - return \class_exists(StaticDriver::class) && StaticDriver::isKeepStaticConnections(); - } - - /** - * @param callable():KernelInterface $createKernel - * @param callable():void $shutdownKernel - */ - public static function resetDatabase(callable $createKernel, callable $shutdownKernel): void - { - if (self::$hasDatabaseBeenReset) { - return; - } - - if ($isDAMADoctrineTestBundleEnabled = self::isDAMADoctrineTestBundleEnabled()) { - // disable static connections for this operation - // :warning: the kernel should not be booted before calling this! - StaticDriver::setKeepStaticConnections(false); - } - - $kernel = $createKernel(); - $configuration = Configuration::instance(); - $strategyClasses = []; - - try { - $strategies = $configuration->persistence()->strategies; - } catch (PersistenceNotAvailable $e) { - if (!\class_exists(TestKernel::class)) { - throw $e; - } - - // allow this to fail if running foundry test suite - return; - } - - foreach ($strategies as $strategy) { - $strategy->resetDatabase($kernel); - $strategyClasses[] = $strategy::class; - } - - if (1 === \count($strategyClasses) && \is_a($strategyClasses[0], AbstractORMPersistenceStrategy::class, allow_string: true)) { - // enable skipping booting the kernel for resetSchema() - self::$ormOnly = true; - } - - if ($isDAMADoctrineTestBundleEnabled && self::$ormOnly) { - // add global stories so they are available after transaction rollback - $configuration->stories->loadGlobalStories(); - } - - if ($isDAMADoctrineTestBundleEnabled) { - // re-enable static connections - StaticDriver::setKeepStaticConnections(true); - } - - $shutdownKernel(); - - self::$hasDatabaseBeenReset = true; - } - - /** - * @param callable():KernelInterface $createKernel - * @param callable():void $shutdownKernel + * @param iterable $strategies */ - public static function resetSchema(callable $createKernel, callable $shutdownKernel): void - { - if (self::canSkipSchemaReset()) { - // can fully skip booting the kernel - return; - } - - $kernel = $createKernel(); - $configuration = Configuration::instance(); - - try { - $strategies = $configuration->persistence()->strategies; - } catch (PersistenceNotAvailable $e) { - if (!\class_exists(TestKernel::class)) { - throw $e; - } - - // allow this to fail if running foundry test suite - return; - } - - foreach ($strategies as $strategy) { - $strategy->resetSchema($kernel); - } - - $configuration->stories->loadGlobalStories(); - - $shutdownKernel(); + public function __construct( + private iterable $strategies, + private ResetDatabaseManager $resetDatabaseManager, + ) { } public function isEnabled(): bool @@ -367,15 +269,30 @@ public function embeddablePropertiesFor(object $object, string $owner): ?array public function hasPersistenceFor(object $object): bool { try { - return (bool) $this->strategyFor($object::class); + return (bool)$this->strategyFor($object::class); } catch (NoPersistenceStrategy) { return false; } } - private static function canSkipSchemaReset(): bool + public function resetDatabaseManager(): ResetDatabaseManager { - return self::$ormOnly && self::isDAMADoctrineTestBundleEnabled(); + return $this->resetDatabaseManager; + } + + public static function isOrmOnly(): bool + { + static $isOrmOnly = null; + + return $isOrmOnly ??= (static function (): bool { + try { + $strategies = iterator_to_array(Configuration::instance()->persistence()->strategies); + } catch (PersistenceNotAvailable) { + $strategies = []; + } + + return count($strategies) === 1 && $strategies[0] instanceof AbstractORMPersistenceStrategy; + })(); } /** diff --git a/src/Persistence/PersistenceStrategy.php b/src/Persistence/PersistenceStrategy.php index 688105db0..975ce2dd9 100644 --- a/src/Persistence/PersistenceStrategy.php +++ b/src/Persistence/PersistenceStrategy.php @@ -11,15 +11,10 @@ namespace Zenstruck\Foundry\Persistence; -use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\Mapping\ClassMetadata; use Doctrine\Persistence\Mapping\MappingException; use Doctrine\Persistence\ObjectManager; -use Symfony\Bundle\FrameworkBundle\Console\Application; -use Symfony\Component\Console\Input\ArrayInput; -use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\HttpKernel\KernelInterface; /** * @author Kevin Bond @@ -89,10 +84,6 @@ abstract public function hasChanges(object $object): bool; abstract public function contains(object $object): bool; - abstract public function resetDatabase(KernelInterface $kernel): void; - - abstract public function resetSchema(KernelInterface $kernel): void; - abstract public function truncate(string $class): void; /** @@ -108,27 +99,4 @@ abstract public function managedNamespaces(): array; abstract public function embeddablePropertiesFor(object $object, string $owner): ?array; abstract public function isEmbeddable(object $object): bool; - - /** - * @param array $parameters - */ - final protected static function runCommand(Application $application, string $command, array $parameters = [], bool $canFail = false): void - { - $exit = $application->run( - new ArrayInput(\array_merge(['command' => $command], $parameters)), - $output = new BufferedOutput() - ); - - if (0 !== $exit && !$canFail) { - throw new \RuntimeException(\sprintf('Error running "%s": %s', $command, $output->fetch())); - } - } - - final protected static function application(KernelInterface $kernel): Application - { - $application = new Application($kernel); - $application->setAutoExit(false); - - return $application; - } } diff --git a/src/Persistence/ResetDatabase/BeforeEachTestResetter.php b/src/Persistence/ResetDatabase/BeforeEachTestResetter.php new file mode 100644 index 000000000..a33acdb29 --- /dev/null +++ b/src/Persistence/ResetDatabase/BeforeEachTestResetter.php @@ -0,0 +1,16 @@ + + */ +interface BeforeEachTestResetter +{ + public function resetBeforeEachTest(KernelInterface $kernel): void; +} diff --git a/src/Persistence/ResetDatabase/BeforeFirstTestResetter.php b/src/Persistence/ResetDatabase/BeforeFirstTestResetter.php new file mode 100644 index 000000000..1a135bbaf --- /dev/null +++ b/src/Persistence/ResetDatabase/BeforeFirstTestResetter.php @@ -0,0 +1,16 @@ + + */ +interface BeforeFirstTestResetter +{ + public function resetBeforeFirstTest(KernelInterface $kernel): void; +} diff --git a/src/Persistence/ResetDatabase/ResetDatabaseManager.php b/src/Persistence/ResetDatabase/ResetDatabaseManager.php new file mode 100644 index 000000000..32f19eedf --- /dev/null +++ b/src/Persistence/ResetDatabase/ResetDatabaseManager.php @@ -0,0 +1,108 @@ + + */ +final class ResetDatabaseManager +{ + private static bool $hasDatabaseBeenReset = false; + + /** + * @param iterable $beforeFirstTestResetters + * @param iterable $beforeEachTestResetter + */ + public function __construct( + private iterable $beforeFirstTestResetters, + private iterable $beforeEachTestResetter + ) { + } + + /** + * @param callable():KernelInterface $createKernel + * @param callable():void $shutdownKernel + */ + public static function resetBeforeFirstTest(callable $createKernel, callable $shutdownKernel): void + { + if (self::$hasDatabaseBeenReset) { + return; + } + + $kernel = $createKernel(); + $configuration = Configuration::instance(); + + try { + $databaseResetters = $configuration->persistence()->resetDatabaseManager()->beforeFirstTestResetters; + } catch (PersistenceNotAvailable $e) { + if (!\class_exists(TestKernel::class)) { + throw $e; + } + + // allow this to fail if running foundry test suite + return; + } + + foreach ($databaseResetters as $databaseResetter) { + $databaseResetter->resetBeforeFirstTest($kernel); + } + + $shutdownKernel(); + + self::$hasDatabaseBeenReset = true; + } + + /** + * @param callable():KernelInterface $createKernel + * @param callable():void $shutdownKernel + */ + public static function resetBeforeEachTest(callable $createKernel, callable $shutdownKernel): void + { + if (self::canSkipSchemaReset()) { + // can fully skip booting the kernel + return; + } + + $kernel = $createKernel(); + $configuration = Configuration::instance(); + + try { + $beforeEachTestResetters = $configuration->persistence()->resetDatabaseManager()->beforeEachTestResetter; + } catch (PersistenceNotAvailable $e) { + if (!\class_exists(TestKernel::class)) { + throw $e; + } + + // allow this to fail if running foundry test suite + return; + } + + foreach ($beforeEachTestResetters as $beforeEachTestResetter) { + $beforeEachTestResetter->resetBeforeEachTest($kernel); + } + + $configuration->stories->loadGlobalStories(); + + $shutdownKernel(); + } + + private static function canSkipSchemaReset(): bool + { + return PersistenceManager::isOrmOnly() && self::isDAMADoctrineTestBundleEnabled(); + } + + public static function isDAMADoctrineTestBundleEnabled(): bool + { + return \class_exists(StaticDriver::class) && StaticDriver::isKeepStaticConnections(); + } +} diff --git a/src/Persistence/SymfonyCommandRunner.php b/src/Persistence/SymfonyCommandRunner.php new file mode 100644 index 000000000..a83a26cbc --- /dev/null +++ b/src/Persistence/SymfonyCommandRunner.php @@ -0,0 +1,40 @@ + + */ +trait SymfonyCommandRunner +{ + /** + * @param array $parameters + */ + final protected static function runCommand(Application $application, string $command, array $parameters = [], bool $canFail = false): void + { + $exit = $application->run( + new ArrayInput(\array_merge(['command' => $command], $parameters + ['--no-interaction' => true])), + $output = new BufferedOutput() + ); + + if (0 !== $exit && !$canFail) { + throw new \RuntimeException(\sprintf('Error running "%s": %s', $command, $output->fetch())); + } + } + + final protected static function application(KernelInterface $kernel): Application + { + $application = new Application($kernel); + $application->setAutoExit(false); + + return $application; + } +} diff --git a/src/Test/ResetDatabase.php b/src/Test/ResetDatabase.php index b2c51810a..1c6475b36 100644 --- a/src/Test/ResetDatabase.php +++ b/src/Test/ResetDatabase.php @@ -16,6 +16,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Foundry\Persistence\PersistenceManager; +use Zenstruck\Foundry\Persistence\ResetDatabase\ResetDatabaseManager; use function Zenstruck\Foundry\restorePhpUnitErrorHandler; /** @@ -28,13 +29,13 @@ trait ResetDatabase * @beforeClass */ #[BeforeClass] - public static function _resetDatabase(): void + public static function _resetDatabaseBeforeFirstTest(): void { if (!\is_subclass_of(static::class, KernelTestCase::class)) { throw new \RuntimeException(\sprintf('The "%s" trait can only be used on TestCases that extend "%s".', __TRAIT__, KernelTestCase::class)); } - PersistenceManager::resetDatabase( + ResetDatabaseManager::resetBeforeFirstTest( static fn() => static::bootKernel(), static function(): void { static::ensureKernelShutdown(); @@ -48,13 +49,13 @@ static function(): void { * @before */ #[Before] - public static function _resetSchema(): void + public static function _resetDatabaseBeforeEachTest(): void { if (!\is_subclass_of(static::class, KernelTestCase::class)) { throw new \RuntimeException(\sprintf('The "%s" trait can only be used on TestCases that extend "%s".', __TRAIT__, KernelTestCase::class)); } - PersistenceManager::resetSchema( + ResetDatabaseManager::resetBeforeEachTest( static fn() => static::bootKernel(), static fn() => static::ensureKernelShutdown(), ); diff --git a/src/ZenstruckFoundryBundle.php b/src/ZenstruckFoundryBundle.php index 2ce4ab13b..41824c26d 100644 --- a/src/ZenstruckFoundryBundle.php +++ b/src/ZenstruckFoundryBundle.php @@ -17,8 +17,10 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\Bundle\AbstractBundle; +use Zenstruck\Foundry\Mongo\MongoResetter; use Zenstruck\Foundry\Object\Instantiator; -use Zenstruck\Foundry\ORM\AbstractORMPersistenceStrategy; +use Zenstruck\Foundry\ORM\ResetDatabase\OrmResetter; +use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; /** * @author Kevin Bond @@ -111,8 +113,12 @@ public function configure(DefinitionConfigurator $definition): void ->end() ->enumNode('mode') ->info('Reset mode to use with ResetDatabase trait') - ->defaultValue(AbstractORMPersistenceStrategy::RESET_MODE_SCHEMA) - ->values([AbstractORMPersistenceStrategy::RESET_MODE_SCHEMA, AbstractORMPersistenceStrategy::RESET_MODE_MIGRATE]) + ->defaultValue(ResetDatabaseMode::SCHEMA) + ->beforeNormalization() + ->ifString() + ->then(static fn(string $mode): ?ResetDatabaseMode => ResetDatabaseMode::tryFrom($mode)) + ->end() + ->values(ResetDatabaseMode::cases()) ->end() ->arrayNode('migrations') ->addDefaultsIfNotSet() @@ -237,6 +243,24 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $container->getDefinition('.zenstruck_foundry.persistence_strategy.orm') ->replaceArgument(1, $config['orm']) ; + + $container->getDefinition('.zenstruck_foundry.persistence.database_resetter.orm.abstract') + ->replaceArgument('$managers', $config['orm']['reset']['entity_managers']) + ->replaceArgument('$connections', $config['orm']['reset']['connections']) + ; + + $container->getDefinition('.zenstruck_foundry.persistence.database_resetter.orm.migrate') + ->replaceArgument('$configurations', $config['orm']['reset']['migrations']['configurations']) + ; + + /** @var ResetDatabaseMode $resetMode */ + $resetMode = $config['orm']['reset']['mode']; + $toRemove = $resetMode === ResetDatabaseMode::SCHEMA ? ResetDatabaseMode::MIGRATE->value : ResetDatabaseMode::SCHEMA->value; + + $container->removeDefinition(".zenstruck_foundry.persistence.database_resetter.orm.$toRemove.dama"); + $container->removeDefinition(".zenstruck_foundry.persistence.database_resetter.orm.$toRemove"); + + $container->setAlias(OrmResetter::class, ".zenstruck_foundry.persistence.database_resetter.orm.{$resetMode->value}"); } if (isset($bundles['DoctrineMongoDBBundle'])) { @@ -245,6 +269,12 @@ public function loadExtension(array $config, ContainerConfigurator $configurator $container->getDefinition('.zenstruck_foundry.persistence_strategy.mongo') ->replaceArgument(1, $config['mongo']) ; + + $container->getDefinition('.zenstruck_foundry.persistence.schema_resetter.mongo') + ->replaceArgument(0, $config['mongo']['reset']['document_managers']) + ; + + $container->setAlias(MongoResetter::class, '.zenstruck_foundry.persistence.schema_resetter.mongo'); } } diff --git a/tests/Fixture/TestKernel.php b/tests/Fixture/TestKernel.php index 049c13c7d..1f22c4dc7 100644 --- a/tests/Fixture/TestKernel.php +++ b/tests/Fixture/TestKernel.php @@ -24,6 +24,7 @@ use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Zenstruck\Foundry\ORM\AbstractORMPersistenceStrategy; +use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\Tests\Fixture\Factories\ArrayFactory; use Zenstruck\Foundry\Tests\Fixture\Factories\Object1Factory; use Zenstruck\Foundry\Tests\Fixture\Stories\GlobalInvokableService; @@ -74,7 +75,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load ], 'orm' => [ 'reset' => [ - 'mode' => \getenv('DATABASE_RESET_MODE') ?: AbstractORMPersistenceStrategy::RESET_MODE_SCHEMA, + 'mode' => \getenv('DATABASE_RESET_MODE') ?: ResetDatabaseMode::SCHEMA, ], ], ]); @@ -105,7 +106,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load ], ]); - if (AbstractORMPersistenceStrategy::RESET_MODE_MIGRATE === \getenv('DATABASE_RESET_MODE')) { + if (ResetDatabaseMode::MIGRATE->value === \getenv('DATABASE_RESET_MODE')) { $c->loadFromExtension('doctrine', [ 'orm' => [ 'mappings' => [ diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 51fb6d505..03193ea6a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -16,7 +16,7 @@ use Symfony\Component\ErrorHandler\ErrorHandler; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; -use Zenstruck\Foundry\ORM\AbstractORMPersistenceStrategy; +use Zenstruck\Foundry\ORM\ResetDatabase\ResetDatabaseMode; use Zenstruck\Foundry\Tests\Fixture\TestKernel; require \dirname(__DIR__).'/vendor/autoload.php'; @@ -27,7 +27,7 @@ (new Dotenv())->usePutenv()->loadEnv(__DIR__.'/../.env'); -if (\getenv('DATABASE_URL') && AbstractORMPersistenceStrategy::RESET_MODE_MIGRATE === \getenv('DATABASE_RESET_MODE')) { +if (\getenv('DATABASE_URL') && ResetDatabaseMode::MIGRATE->value === \getenv('DATABASE_RESET_MODE')) { $fs->remove(__DIR__.'/Fixture/Migrations'); $fs->mkdir(__DIR__.'/Fixture/Migrations');