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