Skip to content

Commit d0a86b7

Browse files
committed
Add cross-driver testsuite for MySQL and Postgres
Refactor DbTest and MapperTest onto a shared DatabaseTestCase so the existing tests run against SQLite, MySQL, and Postgres. The driver, DSN, and credentials are selected from environment variables; default to in-memory SQLite to preserve the current zero-config experience. A docker-compose.yml provides MySQL and Postgres locally on non-default ports, and CI fans out into a three-driver matrix using GitHub Actions services. Fix Mapper::checkNewIdentity to wrap lastInsertId() in a savepoint on Postgres. After an INSERT with an explicit id, Postgres errors with "lastval is not yet defined in this session" and marks the surrounding transaction as aborted; the catch swallowed the exception but the subsequent commit then discarded the row. The savepoint contains the abort. MySQL is excluded because issuing SAVEPOINT between the INSERT and LAST_INSERT_ID() resets the latter to 0. Also fix four raw-SQL portability bugs in MapperTest that SQLite and MySQL had hidden: double-quoted string literals (Postgres reads them as identifiers) and a VARCHAR/integer comparison.
1 parent a3c9be3 commit d0a86b7

13 files changed

Lines changed: 484 additions & 43 deletions

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Driver selection for the integration testsuite.
2+
# Defaults to sqlite (in-memory) when unset.
3+
DB_DRIVER=sqlite
4+
5+
# MySQL (matches docker-compose.yml `mysql` service)
6+
# DB_DRIVER=mysql
7+
# DB_DSN=mysql:host=127.0.0.1;port=33306;dbname=relational_test
8+
# DB_USER=root
9+
# DB_PASSWORD=test
10+
11+
# Postgres (matches docker-compose.yml `postgres` service)
12+
# DB_DRIVER=pgsql
13+
# DB_DSN=pgsql:host=127.0.0.1;port=55432;dbname=relational_test
14+
# DB_USER=postgres
15+
# DB_PASSWORD=test

.github/workflows/ci.yml

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,79 @@ on:
66
pull_request:
77

88
jobs:
9-
tests:
10-
name: Tests
9+
tests-sqlite:
10+
name: Tests (sqlite)
1111
runs-on: ubuntu-latest
1212
steps:
1313
- uses: actions/checkout@v6
1414
- uses: shivammathur/setup-php@v2
1515
with:
1616
php-version: '8.5'
17+
extensions: pdo_sqlite
1718
- uses: ramsey/composer-install@v3
1819
- run: composer phpunit
20+
env:
21+
DB_DRIVER: sqlite
22+
23+
tests-mysql:
24+
name: Tests (mysql)
25+
runs-on: ubuntu-latest
26+
services:
27+
mysql:
28+
image: mysql:8.0
29+
env:
30+
MYSQL_ROOT_PASSWORD: test
31+
MYSQL_DATABASE: relational_test
32+
ports:
33+
- 3306:3306
34+
options: >-
35+
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -ptest"
36+
--health-interval=5s
37+
--health-timeout=5s
38+
--health-retries=20
39+
steps:
40+
- uses: actions/checkout@v6
41+
- uses: shivammathur/setup-php@v2
42+
with:
43+
php-version: '8.5'
44+
extensions: pdo_mysql
45+
- uses: ramsey/composer-install@v3
46+
- run: composer phpunit
47+
env:
48+
DB_DRIVER: mysql
49+
DB_DSN: mysql:host=127.0.0.1;port=3306;dbname=relational_test
50+
DB_USER: root
51+
DB_PASSWORD: test
52+
53+
tests-pgsql:
54+
name: Tests (pgsql)
55+
runs-on: ubuntu-latest
56+
services:
57+
postgres:
58+
image: postgres:16-alpine
59+
env:
60+
POSTGRES_PASSWORD: test
61+
POSTGRES_DB: relational_test
62+
ports:
63+
- 5432:5432
64+
options: >-
65+
--health-cmd="pg_isready -U postgres -d relational_test"
66+
--health-interval=5s
67+
--health-timeout=5s
68+
--health-retries=20
69+
steps:
70+
- uses: actions/checkout@v6
71+
- uses: shivammathur/setup-php@v2
72+
with:
73+
php-version: '8.5'
74+
extensions: pdo_pgsql
75+
- uses: ramsey/composer-install@v3
76+
- run: composer phpunit
77+
env:
78+
DB_DRIVER: pgsql
79+
DB_DSN: pgsql:host=127.0.0.1;port=5432;dbname=relational_test
80+
DB_USER: postgres
81+
DB_PASSWORD: test
1982

2083
code-coverage:
2184
name: Code Coverage

CONTRIBUTING.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ No test should fail.
4848
You can tweak the PHPUnit's settings by copying `phpunit.xml.dist` to `phpunit.xml`
4949
and changing it according to your needs.
5050

51+
### Running tests against MySQL and PostgreSQL
52+
53+
The default `vendor/bin/phpunit` run uses an in-memory SQLite database. To
54+
exercise the full testsuite against MySQL and PostgreSQL as well, start the
55+
bundled containers and use the driver-specific composer scripts:
56+
57+
```shell
58+
docker compose up -d
59+
composer phpunit:sqlite
60+
composer phpunit:mysql
61+
composer phpunit:pgsql
62+
# or all three in sequence:
63+
composer phpunit:all
64+
```
65+
66+
The `docker-compose.yml` exposes MySQL on host port `33306` and PostgreSQL on
67+
`55432` (non-default to avoid conflicts with locally installed databases).
68+
The composer scripts hard-code the credentials defined in `docker-compose.yml`;
69+
override `DB_DRIVER`, `DB_DSN`, `DB_USER`, and `DB_PASSWORD` to point at a
70+
different setup — see `.env.example` for the supported variables.
71+
72+
CI runs the same three-driver matrix on every push and pull request via
73+
GitHub Actions services (no Docker required in CI).
74+
5175
## Standards
5276

5377
We are trying to follow the [PHP-FIG](http://www.php-fig.org)'s standards, so

composer.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@
4545
"phpcs": "vendor/bin/phpcs",
4646
"phpstan": "vendor/bin/phpstan analyze",
4747
"phpunit": "vendor/bin/phpunit",
48+
"phpunit:sqlite": "DB_DRIVER=sqlite vendor/bin/phpunit",
49+
"phpunit:mysql": "DB_DRIVER=mysql DB_USER=root DB_PASSWORD=test vendor/bin/phpunit",
50+
"phpunit:pgsql": "DB_DRIVER=pgsql DB_USER=postgres DB_PASSWORD=test vendor/bin/phpunit",
51+
"phpunit:all": [
52+
"@phpunit:sqlite",
53+
"@phpunit:mysql",
54+
"@phpunit:pgsql"
55+
],
4856
"coverage": "vendor/bin/phpunit --coverage-text",
4957
"qa": [
5058
"@phpcs",

docker-compose.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Host ports (33306, 55432) deliberately differ from the MySQL/Postgres
2+
# defaults so a developer with a local install of either is not blocked.
3+
# CI runs against the standard ports (3306, 5432) via GitHub Actions services.
4+
services:
5+
mysql:
6+
image: mysql:8.0
7+
environment:
8+
MYSQL_ROOT_PASSWORD: test
9+
MYSQL_DATABASE: relational_test
10+
ports:
11+
- "33306:3306"
12+
healthcheck:
13+
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-ptest"]
14+
interval: 5s
15+
timeout: 5s
16+
retries: 20
17+
18+
postgres:
19+
image: postgres:16-alpine
20+
environment:
21+
POSTGRES_PASSWORD: test
22+
POSTGRES_DB: relational_test
23+
ports:
24+
- "55432:5432"
25+
healthcheck:
26+
test: ["CMD-SHELL", "pg_isready -U postgres -d relational_test"]
27+
interval: 5s
28+
timeout: 5s
29+
retries: 20

phpunit.xml.dist

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
bootstrap="vendor/autoload.php">
55
<testsuites>
66
<testsuite name="unit">
7-
<directory suffix="Test.php">tests</directory>
7+
<file>tests/SqlTest.php</file>
8+
</testsuite>
9+
<testsuite name="database">
10+
<file>tests/DbTest.php</file>
11+
<file>tests/MapperTest.php</file>
812
</testsuite>
913
</testsuites>
1014
<source>

src/Mapper.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,12 +205,35 @@ private function rawInsert(
205205

206206
private function checkNewIdentity(object $entity, Scope $scope): bool
207207
{
208+
$conn = $this->db->connection;
209+
210+
// Postgres aborts the surrounding transaction when lastInsertId() is
211+
// called and no sequence has fired in the session (e.g. the row was
212+
// inserted with an explicit id). Wrap in a savepoint so the abort is
213+
// contained and the insert survives the commit. MySQL is excluded
214+
// because issuing SAVEPOINT between the INSERT and LAST_INSERT_ID()
215+
// resets the latter to 0.
216+
$useSavepoint = $conn->inTransaction()
217+
&& $conn->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql';
218+
219+
if ($useSavepoint) {
220+
$conn->exec('SAVEPOINT mapper_lastid');
221+
}
222+
208223
try {
209-
$identity = $this->db->connection->lastInsertId();
224+
$identity = $conn->lastInsertId();
210225
} catch (PDOException) {
226+
if ($useSavepoint) {
227+
$conn->exec('ROLLBACK TO SAVEPOINT mapper_lastid');
228+
}
229+
211230
return false;
212231
}
213232

233+
if ($useSavepoint) {
234+
$conn->exec('RELEASE SAVEPOINT mapper_lastid');
235+
}
236+
214237
if (!$identity) {
215238
return false;
216239
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\Relational\Database;
6+
7+
use InvalidArgumentException;
8+
use PDO;
9+
10+
use function getenv;
11+
use function in_array;
12+
13+
final class ConnectionFactory
14+
{
15+
public static function driver(): string
16+
{
17+
$driver = getenv('DB_DRIVER');
18+
if ($driver === false || $driver === '') {
19+
return 'sqlite';
20+
}
21+
22+
return $driver;
23+
}
24+
25+
/** @throws DriverUnavailable */
26+
public static function create(): PDO
27+
{
28+
$driver = self::driver();
29+
if (!in_array($driver, PDO::getAvailableDrivers(), true)) {
30+
throw new DriverUnavailable($driver);
31+
}
32+
33+
$dsn = self::dsn($driver);
34+
$user = self::env('DB_USER');
35+
$password = self::env('DB_PASSWORD');
36+
37+
$pdo = new PDO($dsn, $user, $password);
38+
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
39+
40+
return $pdo;
41+
}
42+
43+
private static function dsn(string $driver): string
44+
{
45+
$dsn = self::env('DB_DSN');
46+
if ($dsn !== null) {
47+
return $dsn;
48+
}
49+
50+
return match ($driver) {
51+
'sqlite' => 'sqlite::memory:',
52+
'mysql' => 'mysql:host=127.0.0.1;port=33306;dbname=relational_test',
53+
'pgsql' => 'pgsql:host=127.0.0.1;port=55432;dbname=relational_test',
54+
default => throw new InvalidArgumentException('Unsupported driver: ' . $driver),
55+
};
56+
}
57+
58+
private static function env(string $name): string|null
59+
{
60+
$value = getenv($name);
61+
if ($value === false || $value === '') {
62+
return null;
63+
}
64+
65+
return $value;
66+
}
67+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\Relational\Database;
6+
7+
use PDO;
8+
use PHPUnit\Framework\TestCase;
9+
10+
abstract class DatabaseTestCase extends TestCase
11+
{
12+
protected PDO $conn;
13+
14+
protected string $driver;
15+
16+
protected function setUp(): void
17+
{
18+
try {
19+
$this->conn = ConnectionFactory::create();
20+
} catch (DriverUnavailable $e) {
21+
$this->markTestSkipped($e->getMessage());
22+
}
23+
24+
$this->driver = ConnectionFactory::driver();
25+
}
26+
27+
protected function resetTables(string ...$names): void
28+
{
29+
foreach ($names as $name) {
30+
$this->conn->exec('DROP TABLE IF EXISTS ' . $name);
31+
}
32+
}
33+
34+
/**
35+
* Resync Postgres IDENTITY sequences after fixtures with explicit IDs.
36+
* No-op for sqlite and mysql (sqlite picks max+1 automatically; mysql
37+
* advances AUTO_INCREMENT on explicit insert).
38+
*
39+
* @param array<string, string> $tables map of table name to id column name
40+
*/
41+
protected function syncSequences(array $tables): void
42+
{
43+
if ($this->driver !== 'pgsql') {
44+
return;
45+
}
46+
47+
foreach ($tables as $table => $idColumn) {
48+
$this->conn->exec(
49+
"SELECT setval(pg_get_serial_sequence('" . $table . "', '" . $idColumn . "'), "
50+
. 'COALESCE((SELECT MAX(' . $idColumn . ') FROM ' . $table . '), 1))',
51+
);
52+
}
53+
}
54+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\Relational\Database;
6+
7+
use RuntimeException;
8+
9+
final class DriverUnavailable extends RuntimeException
10+
{
11+
public function __construct(public readonly string $driver)
12+
{
13+
parent::__construct('PDO driver "' . $driver . '" is not available');
14+
}
15+
}

0 commit comments

Comments
 (0)