Skip to content

Commit 4b030d4

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 4b030d4

15 files changed

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

0 commit comments

Comments
 (0)