From 684152a3acd06a9c72a14c43b611273207e1d3e3 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Fri, 17 Jul 2026 17:46:24 +0200 Subject: [PATCH] SharedDatabases: Initiate only a single connection per database Since support for PHPUnit 10 was added, the data provider is being evaluated per class usage and only once per run, meaning that the setup and tear down routines for the schema are processed multiple times. This on its own isn't a problem, but test fixtures will add up during execution of multiple independent test cases and this wasn't intended when this trait was introduced. This change introduces a specific singleton to maintain a global state across different test cases in order to have only a single connection per database. It also orchestrates schema handling better since it will be done per test case again now and is even adjustable to support shared state between different test cases. Each transaction can also be put into a database transaction which is rolled back automatically to ensure that no state, that is not part of the schema set-up, influences subsequent tests. Also drops the following methods that were not marked internal: - getEnvironmentVariable - getConnectionConfig --- src/Test/SharedDatabases.php | 157 ++++++++++-------- src/Test/SharedDatabases/SchemaGroup.php | 20 +++ src/Test/SharedDatabases/State.php | 113 +++++++++++++ .../SharedDatabases/TransactionIsolation.php | 15 ++ tests/SharedDatabasesTest.php | 70 ++++++-- 5 files changed, 291 insertions(+), 84 deletions(-) create mode 100644 src/Test/SharedDatabases/SchemaGroup.php create mode 100644 src/Test/SharedDatabases/State.php create mode 100644 src/Test/SharedDatabases/TransactionIsolation.php diff --git a/src/Test/SharedDatabases.php b/src/Test/SharedDatabases.php index 5c54fb2..a80fced 100644 --- a/src/Test/SharedDatabases.php +++ b/src/Test/SharedDatabases.php @@ -2,16 +2,27 @@ namespace ipl\Sql\Test; -use ipl\Sql\Config; use ipl\Sql\Connection; +use ipl\Sql\Select; +use ipl\Sql\Test\SharedDatabases\SchemaGroup; +use ipl\Sql\Test\SharedDatabases\State; +use ipl\Sql\Test\SharedDatabases\TransactionIsolation; +use ReflectionClass; use RuntimeException; +use Throwable; /** * Data provider for database connections. Use this to provide real database connections for your tests. * - * To use it, implement {@see Databases::setUpSchema()} and {@see Databases::tearDownSchema()}. + * In contrast to {@see Databases}, this trait will only initialize the schema once for each test case. + * When PHPUnit runs a test case, the schema will first be dropped and then re-created. This can be + * customized using the {@see SchemaGroup} attribute which allows to share the same schema across + * multiple test cases. Each individual test can be isolated in its own transaction by using the + * {@see TransactionIsolation} attribute. + * + * To use it, implement {@see self::setUpSchema()} and {@see self::tearDownSchema()}. * The environment also needs to provide the following variables: (Replace * with the name of a supported adapter) - * {@see Databases::SUPPORTED_ADAPTERS} + * {@see State::SUPPORTED_ADAPTERS} * * Name | Description * ----------------- | ------------------------ @@ -24,26 +35,17 @@ * Each test case will run multiple times, once for each database. * The connection is passed as the first argument to it. * - * A schema will be initialized once the first test case using this provider is run. Schemas will first be dropped - * and then recreated to ensure a clean state. During the entire test run, the same schema will be used for all - * tests. - * - * If you need to implement your own setUp() and tearDown() methods, and need access to the database connection, - * use {@see Databases::getConnection()}. + * If you need access to the database connection outside a test case, use {@see self::getConnection()}. + * If you need to implement your own setUp() method, make sure to call {@see self::rollbackChanges()}. + * If you need to implement your own setUpBeforeClass() method, make sure to call {@see self::processAnnotations()}. */ trait SharedDatabases { /** - * All database connections - * - * @internal Only the trait itself should access this property - * - * @var array + * @var bool Whether transaction isolation is used + * @internal Only the trait {@see SharedDatabases} must access this property */ - private static array $connections = []; - - /** @var string[] */ - private const SUPPORTED_ADAPTERS = ['mssql', 'mysql', 'oracle', 'pgsql', 'sqlite']; + private static bool $transactionIsolation = false; /** * Create the schema for the test database @@ -72,9 +74,7 @@ abstract protected static function tearDownSchema(Connection $db, string $driver */ final public static function sharedDatabases(): array { - self::initializeDatabases(); - - return self::$connections; + return State::databases(); } /** @@ -102,74 +102,95 @@ final protected function getConnection(): Connection } /** - * Get the value of an environment variable - * - * @param string $name - * - * @return string + * Roll back all changes made by any previous test run * - * @throws RuntimeException if the environment variable is not set + * @return void */ - final protected static function getEnvironmentVariable(string $name): string + final protected function rollbackChanges(): void { - $value = getenv($name); - if ($value === false) { - throw new RuntimeException("Environment variable $name is not set"); + if (! self::$transactionIsolation) { + return; } - return $value; + $connection = $this->getConnection(); + while ($connection->inTransaction()) { + $connection->rollBackTransaction(); + } + + $connection->beginTransaction(); } /** - * Get the connection configuration for the test database + * Re-create the database schemas if required * - * @param string $driver + * @param string $group * - * @return Config + * @return void */ - final protected static function getConnectionConfig(string $driver): Config + final protected static function maintainSchema(string $group): void { - return new Config([ - 'db' => $driver, - 'host' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_HOST'), - 'port' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_PORT'), - 'username' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_USER'), - 'password' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_PASSWORD'), - 'dbname' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB') - ]); + foreach (self::sharedDatabases() as $driver => [$connection]) { + // Ensure to exit any active transaction a previous test case may have initiated + while ($connection->inTransaction()) { + $connection->rollBackTransaction(); + } + + try { + $currentGroup = $connection->select( + (new Select()) + ->columns('__test_group.name') + ->from('__test_group') + )->fetchColumn(); + } catch (Throwable) { + $currentGroup = null; + } + + if ($currentGroup !== $group) { + $connection->exec(sprintf( + 'DROP TABLE IF EXISTS %s', + $connection->quoteIdentifier('__test_group') + )); + static::tearDownSchema($connection, $driver); + static::setUpSchema($connection, $driver); + $connection->exec(sprintf( + 'CREATE TABLE %1$s (name VARCHAR(255))', + $connection->quoteIdentifier('__test_group') + )); + $connection->insert('__test_group', ['name' => $group]); + } + } } /** - * Create a database connection + * Process trait-specific class annotations * - * @param string $driver - * - * @return Connection + * @param string $class * - * @internal Only the trait itself should call this method + * @return void */ - final protected static function connectToDatabase(string $driver): Connection + final protected static function processAnnotations(string $class): void { - return new Connection(self::getConnectionConfig($driver)); + $refClass = new ReflectionClass($class); + + $group = $class; + $attributes = $refClass->getAttributes(SchemaGroup::class); + if (! empty($attributes)) { + $group = $attributes[0]->newInstance()->name; + } + + self::maintainSchema(State::uniqueGroup($group)); + + $attributes = $refClass->getAttributes(TransactionIsolation::class); + self::$transactionIsolation = ! empty($attributes); } - /** - * Set up the database connections - * - * @return void - * - * @internal Only the trait itself should call this method - */ - final protected static function initializeDatabases(): void + public static function setUpBeforeClass(): void { - foreach (self::SUPPORTED_ADAPTERS as $driver) { - if (isset($_SERVER[strtoupper($driver) . '_TESTDB'])) { - if (! isset(self::$connections[$driver])) { - self::$connections[$driver] = [self::connectToDatabase($driver)]; - static::tearDownSchema(self::$connections[$driver][0], $driver); - static::setUpSchema(self::$connections[$driver][0], $driver); - } - } - } + self::processAnnotations(static::class); + } + + public function setUp(): void + { + $this->rollbackChanges(); } } diff --git a/src/Test/SharedDatabases/SchemaGroup.php b/src/Test/SharedDatabases/SchemaGroup.php new file mode 100644 index 0000000..2f3e28b --- /dev/null +++ b/src/Test/SharedDatabases/SchemaGroup.php @@ -0,0 +1,20 @@ + */ + private static array $connections = []; + + /** @var ?string ID to make schema groups unique per run */ + private static ?string $groupSalt = null; + + /** + * Set up the database connections + * + * @return array + */ + public static function databases(): array + { + if (empty(self::$connections)) { + foreach (self::SUPPORTED_ADAPTERS as $driver) { + if (isset($_SERVER[strtoupper($driver) . '_TESTDB'])) { + self::$connections[$driver] = [self::connectToDatabase($driver)]; + } + } + } + + return self::$connections; + } + + /** + * Get a unique (per test run) identifier for the given group + * + * @param string $group + * + * @return string + */ + public static function uniqueGroup(string $group): string + { + if (self::$groupSalt === null) { + self::$groupSalt = uniqid(); + } + + return sha1($group . self::$groupSalt); + } + + /** + * Create a database connection + * + * @param string $driver + * + * @return Connection + */ + private static function connectToDatabase(string $driver): Connection + { + return new Connection(self::getConnectionConfig($driver)); + } + + /** + * Get the connection configuration for the test database + * + * @param string $driver + * + * @return Config + */ + private static function getConnectionConfig(string $driver): Config + { + return new Config([ + 'db' => $driver, + 'host' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_HOST'), + 'port' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_PORT'), + 'username' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_USER'), + 'password' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB_PASSWORD'), + 'dbname' => self::getEnvironmentVariable(strtoupper($driver) . '_TESTDB') + ]); + } + + /** + * Get the value of an environment variable + * + * @param string $name + * + * @return string + * + * @throws RuntimeException if the environment variable is not set + */ + private static function getEnvironmentVariable(string $name): string + { + $value = getenv($name); + if ($value === false) { + throw new RuntimeException("Environment variable $name is not set"); + } + + return $value; + } +} diff --git a/src/Test/SharedDatabases/TransactionIsolation.php b/src/Test/SharedDatabases/TransactionIsolation.php new file mode 100644 index 0000000..14ed203 --- /dev/null +++ b/src/Test/SharedDatabases/TransactionIsolation.php @@ -0,0 +1,15 @@ +select((new Select())->columns('name')->from('test'))->fetchAll(); - $this->assertEmpty($result); + $this->assertNotNull( + $db->select( + (new Select()) + ->columns('name') + ->from('__test_group') + )->fetchColumn(), + 'No database group has been established.' + ); + } + #[DataProvider('sharedDatabases')] + public function testInsert(Connection $db) + { $db->insert('test', ['name' => 'test']); $db->insert('test', ['name' => 'test2']); + + $result = $db->select( + (new Select()) + ->columns('name') + ->from('test') + )->fetchAll(PDO::FETCH_ASSOC); + + $this->assertSame( + [['name' => 'test'], ['name' => 'test2']], + $result + ); } #[Depends('testInsert')] #[DataProvider('sharedDatabases')] public function testSelect(Connection $db) { - // The previous case inserts "name=test" but tearDown removes it - $result = $db->select((new Select())->columns('name')->from('test'))->fetchAll(); + $db->insert('test', ['name' => 'test']); + + $result = $db->select( + (new Select()) + ->columns('name') + ->from('test') + )->fetchAll(PDO::FETCH_ASSOC); + $this->assertCount(1, $result); - $this->assertSame('test2', $result[0]['name']); + $this->assertSame('test', $result[0]['name']); } #[Depends('testSelect')] #[DataProvider('sharedDatabases')] public function testUpdate(Connection $db) { + $db->insert('test', ['name' => 'test']); + $db->insert('test', ['name' => 'test2']); + $stmt = $db->update('test', ['name' => 'test3'], ['name = ?' => 'test2']); $this->assertEquals(1, $stmt->rowCount()); + + $result = $db->select( + (new Select()) + ->columns('name') + ->from('test') + )->fetchAll(PDO::FETCH_ASSOC); + + $this->assertSame( + [['name' => 'test'], ['name' => 'test3']], + $result + ); } - #[Depends('testUpdate')] + #[Depends('testInsert')] #[DataProvider('sharedDatabases')] public function testDelete(Connection $db) { - $stmt = $db->delete('test', ['name = ?' => 'test3']); + $db->insert('test', ['name' => 'test']); + $stmt = $db->delete('test', ['name = ?' => 'test']); $this->assertEquals(1, $stmt->rowCount()); } - protected static function setUpSchema(Connection $db, string $driver): void + public static function setUpSchema(Connection $db, string $driver): void { $db->exec('CREATE TABLE test (name VARCHAR(255))'); } - protected static function tearDownSchema(Connection $db, string $driver): void + public static function tearDownSchema(Connection $db, string $driver): void { $db->exec('DROP TABLE IF EXISTS test'); } - - public function tearDown(): void - { - $this->getConnection()->delete('test', ['name = ?' => ['test']]); - } }