Skip to content

Commit 5b2a825

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 5b2a825

15 files changed

Lines changed: 617 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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44
bootstrap="vendor/autoload.php">
55
<testsuites>
66
<testsuite name="unit">
7-
<directory suffix="Test.php">tests</directory>
7+
<file>tests/ConnectionFactoryTest.php</file>
8+
<file>tests/SqlTest.php</file>
9+
</testsuite>
10+
<testsuite name="database">
11+
<file>tests/DbTest.php</file>
12+
<file>tests/MapperTest.php</file>
813
</testsuite>
914
</testsuites>
1015
<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 respect_relational_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 respect_relational_lastid');
228+
}
229+
211230
return false;
212231
}
213232

233+
if ($useSavepoint) {
234+
$conn->exec('RELEASE SAVEPOINT respect_relational_lastid');
235+
}
236+
214237
if (!$identity) {
215238
return false;
216239
}

tests/ConnectionFactoryTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\Relational;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\TestCase;
9+
use Respect\Relational\Database\ConnectionFactory;
10+
use Respect\Relational\Database\InvalidDriverConfiguration;
11+
12+
use function getenv;
13+
use function putenv;
14+
15+
#[CoversClass(ConnectionFactory::class)]
16+
class ConnectionFactoryTest extends TestCase
17+
{
18+
/** @var array<string, string|false> */
19+
private array $envBackup = [];
20+
21+
protected function setUp(): void
22+
{
23+
foreach (['DB_DRIVER', 'DB_DSN', 'DB_USER', 'DB_PASSWORD'] as $name) {
24+
$this->envBackup[$name] = getenv($name);
25+
putenv($name);
26+
}
27+
}
28+
29+
public function testDefaultsToSqliteWhenEnvUnset(): void
30+
{
31+
$this->assertSame('sqlite', ConnectionFactory::driver());
32+
}
33+
34+
public function testHonorsDbDriverEnvVar(): void
35+
{
36+
putenv('DB_DRIVER=mysql');
37+
$this->assertSame('mysql', ConnectionFactory::driver());
38+
}
39+
40+
public function testDsnSchemeIsAuthoritativeWhenDbDriverUnset(): void
41+
{
42+
putenv('DB_DSN=pgsql:host=foo;dbname=bar');
43+
$this->assertSame('pgsql', ConnectionFactory::driver());
44+
}
45+
46+
public function testAcceptsMatchingDbDriverAndDsnScheme(): void
47+
{
48+
putenv('DB_DRIVER=mysql');
49+
putenv('DB_DSN=mysql:host=foo;dbname=bar');
50+
$this->assertSame('mysql', ConnectionFactory::driver());
51+
}
52+
53+
public function testThrowsWhenDbDriverAndDsnSchemeDisagree(): void
54+
{
55+
putenv('DB_DRIVER=mysql');
56+
putenv('DB_DSN=pgsql:host=foo;dbname=bar');
57+
$this->expectException(InvalidDriverConfiguration::class);
58+
$this->expectExceptionMessage('DB_DRIVER (mysql) does not match the scheme of DB_DSN (pgsql)');
59+
ConnectionFactory::driver();
60+
}
61+
62+
public function testThrowsWhenDsnHasNoScheme(): void
63+
{
64+
putenv('DB_DSN=no-colon-here');
65+
$this->expectException(InvalidDriverConfiguration::class);
66+
ConnectionFactory::driver();
67+
}
68+
69+
public function testThrowsWhenDsnStartsWithColon(): void
70+
{
71+
putenv('DB_DSN=:nothing-before-colon');
72+
$this->expectException(InvalidDriverConfiguration::class);
73+
ConnectionFactory::driver();
74+
}
75+
76+
protected function tearDown(): void
77+
{
78+
foreach ($this->envBackup as $name => $value) {
79+
if ($value === false) {
80+
putenv($name);
81+
} else {
82+
putenv($name . '=' . $value);
83+
}
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)