Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 89 additions & 68 deletions src/Test/SharedDatabases.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
* ----------------- | ------------------------
Expand All @@ -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
Expand Down Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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();
}
}
20 changes: 20 additions & 0 deletions src/Test/SharedDatabases/SchemaGroup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace ipl\Sql\Test\SharedDatabases;

use Attribute;
use ipl\Sql\Test\SharedDatabases;

/**
* A test case using the trait {@see SharedDatabases} can be associated to a group using this attribute
* in order to share the database schema between all test cases of the same group. By default, each class
* using the trait will re-create the schema upon its run.
*/
#[Attribute(flags: Attribute::TARGET_CLASS)]
final readonly class SchemaGroup
{
public function __construct(
public string $name
) {
}
}
113 changes: 113 additions & 0 deletions src/Test/SharedDatabases/State.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

namespace ipl\Sql\Test\SharedDatabases;

use ipl\Sql\Config;
use ipl\Sql\Connection;
use ipl\Sql\Test\SharedDatabases;
use RuntimeException;

/**
* Shared state for database connections used by the {@see SharedDatabases} trait.
*
* It's a final singleton because it's only used internally by the trait to ensure
* a single connection for each supported driver. Otherwise tests cannot isolate
* their cases in individual transactions.
*
* @internal Must only be used by the {@see SharedDatabases} trait.
*/
final class State
{
/** @var string[] */
private const SUPPORTED_ADAPTERS = ['mssql', 'mysql', 'oracle', 'pgsql', 'sqlite'];

/** @var array<string, Connection> */
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<string, Connection[]>
*/
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;
}
}
15 changes: 15 additions & 0 deletions src/Test/SharedDatabases/TransactionIsolation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace ipl\Sql\Test\SharedDatabases;

use Attribute;
use ipl\Sql\Test\SharedDatabases;

/**
* A test case using the {@see SharedDatabases} trait can be marked with this attribute to isolate
* each test in its own transaction.
*/
#[Attribute(flags: Attribute::TARGET_CLASS)]
final class TransactionIsolation
{
}
Loading
Loading