diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9fc944a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# Driver selection for the integration testsuite.
+# Defaults to sqlite (in-memory) when unset.
+DB_DRIVER=sqlite
+
+# MySQL (matches docker-compose.yml `mysql` service)
+# DB_DRIVER=mysql
+# DB_DSN=mysql:host=127.0.0.1;port=33306;dbname=relational_test
+# DB_USER=root
+# DB_PASSWORD=test
+
+# Postgres (matches docker-compose.yml `postgres` service)
+# DB_DRIVER=pgsql
+# DB_DSN=pgsql:host=127.0.0.1;port=55432;dbname=relational_test
+# DB_USER=postgres
+# DB_PASSWORD=test
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d99ab21..ea4d720 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,16 +6,79 @@ on:
pull_request:
jobs:
- tests:
- name: Tests
+ tests-sqlite:
+ name: Tests (sqlite)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: shivammathur/setup-php@v2
with:
php-version: '8.5'
+ extensions: pdo_sqlite
- uses: ramsey/composer-install@v3
- run: composer phpunit
+ env:
+ DB_DRIVER: sqlite
+
+ tests-mysql:
+ name: Tests (mysql)
+ runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_ROOT_PASSWORD: test
+ MYSQL_DATABASE: relational_test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -ptest"
+ --health-interval=5s
+ --health-timeout=5s
+ --health-retries=20
+ steps:
+ - uses: actions/checkout@v6
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.5'
+ extensions: pdo_mysql
+ - uses: ramsey/composer-install@v3
+ - run: composer phpunit
+ env:
+ DB_DRIVER: mysql
+ DB_DSN: mysql:host=127.0.0.1;port=3306;dbname=relational_test
+ DB_USER: root
+ DB_PASSWORD: test
+
+ tests-pgsql:
+ name: Tests (pgsql)
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_PASSWORD: test
+ POSTGRES_DB: relational_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd="pg_isready -U postgres -d relational_test"
+ --health-interval=5s
+ --health-timeout=5s
+ --health-retries=20
+ steps:
+ - uses: actions/checkout@v6
+ - uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.5'
+ extensions: pdo_pgsql
+ - uses: ramsey/composer-install@v3
+ - run: composer phpunit
+ env:
+ DB_DRIVER: pgsql
+ DB_DSN: pgsql:host=127.0.0.1;port=5432;dbname=relational_test
+ DB_USER: postgres
+ DB_PASSWORD: test
code-coverage:
name: Code Coverage
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 366448f..115c146 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -48,6 +48,30 @@ No test should fail.
You can tweak the PHPUnit's settings by copying `phpunit.xml.dist` to `phpunit.xml`
and changing it according to your needs.
+### Running tests against MySQL and PostgreSQL
+
+The default `vendor/bin/phpunit` run uses an in-memory SQLite database. To
+exercise the full testsuite against MySQL and PostgreSQL as well, start the
+bundled containers and use the driver-specific composer scripts:
+
+```shell
+docker compose up -d
+composer phpunit:sqlite
+composer phpunit:mysql
+composer phpunit:pgsql
+# or all three in sequence:
+composer phpunit:all
+```
+
+The `docker-compose.yml` exposes MySQL on host port `33306` and PostgreSQL on
+`55432` (non-default to avoid conflicts with locally installed databases).
+The composer scripts hard-code the credentials defined in `docker-compose.yml`;
+override `DB_DRIVER`, `DB_DSN`, `DB_USER`, and `DB_PASSWORD` to point at a
+different setup — see `.env.example` for the supported variables.
+
+CI runs the same three-driver matrix on every push and pull request via
+GitHub Actions services (no Docker required in CI).
+
## Standards
We are trying to follow the [PHP-FIG](http://www.php-fig.org)'s standards, so
diff --git a/composer.json b/composer.json
index efe4406..78e0a95 100644
--- a/composer.json
+++ b/composer.json
@@ -45,6 +45,14 @@
"phpcs": "vendor/bin/phpcs",
"phpstan": "vendor/bin/phpstan analyze",
"phpunit": "vendor/bin/phpunit",
+ "phpunit:sqlite": "DB_DRIVER=sqlite vendor/bin/phpunit",
+ "phpunit:mysql": "DB_DRIVER=mysql DB_USER=root DB_PASSWORD=test vendor/bin/phpunit",
+ "phpunit:pgsql": "DB_DRIVER=pgsql DB_USER=postgres DB_PASSWORD=test vendor/bin/phpunit",
+ "phpunit:all": [
+ "@phpunit:sqlite",
+ "@phpunit:mysql",
+ "@phpunit:pgsql"
+ ],
"coverage": "vendor/bin/phpunit --coverage-text",
"qa": [
"@phpcs",
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..9ecf01e
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,29 @@
+# Host ports (33306, 55432) deliberately differ from the MySQL/Postgres
+# defaults so a developer with a local install of either is not blocked.
+# CI runs against the standard ports (3306, 5432) via GitHub Actions services.
+services:
+ mysql:
+ image: mysql:8.0
+ environment:
+ MYSQL_ROOT_PASSWORD: test
+ MYSQL_DATABASE: relational_test
+ ports:
+ - "33306:3306"
+ healthcheck:
+ test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-ptest"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
+
+ postgres:
+ image: postgres:16-alpine
+ environment:
+ POSTGRES_PASSWORD: test
+ POSTGRES_DB: relational_test
+ ports:
+ - "55432:5432"
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres -d relational_test"]
+ interval: 5s
+ timeout: 5s
+ retries: 20
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index d4c8e3b..0d2ebe2 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -4,7 +4,12 @@
bootstrap="vendor/autoload.php">
- tests
+ tests/ConnectionFactoryTest.php
+ tests/SqlTest.php
+
+
+ tests/DbTest.php
+ tests/MapperTest.php
diff --git a/src/Mapper.php b/src/Mapper.php
index 69fe6d9..7ba52fb 100644
--- a/src/Mapper.php
+++ b/src/Mapper.php
@@ -205,12 +205,35 @@ private function rawInsert(
private function checkNewIdentity(object $entity, Scope $scope): bool
{
+ $conn = $this->db->connection;
+
+ // Postgres aborts the surrounding transaction when lastInsertId() is
+ // called and no sequence has fired in the session (e.g. the row was
+ // inserted with an explicit id). Wrap in a savepoint so the abort is
+ // contained and the insert survives the commit. MySQL is excluded
+ // because issuing SAVEPOINT between the INSERT and LAST_INSERT_ID()
+ // resets the latter to 0.
+ $useSavepoint = $conn->inTransaction()
+ && $conn->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql';
+
+ if ($useSavepoint) {
+ $conn->exec('SAVEPOINT respect_relational_lastid');
+ }
+
try {
- $identity = $this->db->connection->lastInsertId();
+ $identity = $conn->lastInsertId();
} catch (PDOException) {
+ if ($useSavepoint) {
+ $conn->exec('ROLLBACK TO SAVEPOINT respect_relational_lastid');
+ }
+
return false;
}
+ if ($useSavepoint) {
+ $conn->exec('RELEASE SAVEPOINT respect_relational_lastid');
+ }
+
if (!$identity) {
return false;
}
diff --git a/tests/ConnectionFactoryTest.php b/tests/ConnectionFactoryTest.php
new file mode 100644
index 0000000..b8f6fbc
--- /dev/null
+++ b/tests/ConnectionFactoryTest.php
@@ -0,0 +1,84 @@
+ */
+ private array $envBackup = [];
+
+ protected function setUp(): void
+ {
+ foreach (['DB_DRIVER', 'DB_DSN', 'DB_USER', 'DB_PASSWORD'] as $name) {
+ $this->envBackup[$name] = getenv($name);
+ putenv($name);
+ }
+ }
+
+ public function testDefaultsToSqliteWhenEnvUnset(): void
+ {
+ $this->assertSame('sqlite', ConnectionFactory::driver());
+ }
+
+ public function testHonorsDbDriverEnvVar(): void
+ {
+ putenv('DB_DRIVER=mysql');
+ $this->assertSame('mysql', ConnectionFactory::driver());
+ }
+
+ public function testDsnSchemeIsAuthoritativeWhenDbDriverUnset(): void
+ {
+ putenv('DB_DSN=pgsql:host=foo;dbname=bar');
+ $this->assertSame('pgsql', ConnectionFactory::driver());
+ }
+
+ public function testAcceptsMatchingDbDriverAndDsnScheme(): void
+ {
+ putenv('DB_DRIVER=mysql');
+ putenv('DB_DSN=mysql:host=foo;dbname=bar');
+ $this->assertSame('mysql', ConnectionFactory::driver());
+ }
+
+ public function testThrowsWhenDbDriverAndDsnSchemeDisagree(): void
+ {
+ putenv('DB_DRIVER=mysql');
+ putenv('DB_DSN=pgsql:host=foo;dbname=bar');
+ $this->expectException(InvalidDriverConfiguration::class);
+ $this->expectExceptionMessage('DB_DRIVER (mysql) does not match the scheme of DB_DSN (pgsql)');
+ ConnectionFactory::driver();
+ }
+
+ public function testThrowsWhenDsnHasNoScheme(): void
+ {
+ putenv('DB_DSN=no-colon-here');
+ $this->expectException(InvalidDriverConfiguration::class);
+ ConnectionFactory::driver();
+ }
+
+ public function testThrowsWhenDsnStartsWithColon(): void
+ {
+ putenv('DB_DSN=:nothing-before-colon');
+ $this->expectException(InvalidDriverConfiguration::class);
+ ConnectionFactory::driver();
+ }
+
+ protected function tearDown(): void
+ {
+ foreach ($this->envBackup as $name => $value) {
+ if ($value === false) {
+ putenv($name);
+ } else {
+ putenv($name . '=' . $value);
+ }
+ }
+ }
+}
diff --git a/tests/Database/ConnectionFactory.php b/tests/Database/ConnectionFactory.php
new file mode 100644
index 0000000..f6e31f7
--- /dev/null
+++ b/tests/Database/ConnectionFactory.php
@@ -0,0 +1,100 @@
+setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+
+ return $pdo;
+ }
+
+ private static function dsn(string $driver): string
+ {
+ $dsn = self::env('DB_DSN');
+ if ($dsn !== null) {
+ return $dsn;
+ }
+
+ return match ($driver) {
+ 'sqlite' => 'sqlite::memory:',
+ 'mysql' => 'mysql:host=127.0.0.1;port=33306;dbname=relational_test',
+ 'pgsql' => 'pgsql:host=127.0.0.1;port=55432;dbname=relational_test',
+ default => throw new InvalidArgumentException('Unsupported driver: ' . $driver),
+ };
+ }
+
+ private static function extractDsnScheme(string $dsn): string
+ {
+ $colon = strpos($dsn, ':');
+ if ($colon === false || $colon === 0) {
+ throw new InvalidDriverConfiguration(
+ 'DB_DSN must start with a driver scheme like "mysql:", got: ' . $dsn,
+ );
+ }
+
+ return substr($dsn, 0, $colon);
+ }
+
+ private static function env(string $name): string|null
+ {
+ $value = getenv($name);
+ if ($value === false || $value === '') {
+ return null;
+ }
+
+ return $value;
+ }
+}
diff --git a/tests/Database/DatabaseTestCase.php b/tests/Database/DatabaseTestCase.php
new file mode 100644
index 0000000..14babc8
--- /dev/null
+++ b/tests/Database/DatabaseTestCase.php
@@ -0,0 +1,56 @@
+conn = ConnectionFactory::create();
+ } catch (DriverUnavailable $e) {
+ $this->markTestSkipped($e->getMessage());
+ }
+
+ // Read the driver back from the live PDO instead of trusting the env,
+ // so a misconfigured DB_DRIVER cannot push tests onto the wrong schema.
+ $this->driver = $this->conn->getAttribute(PDO::ATTR_DRIVER_NAME);
+ }
+
+ protected function resetTables(string ...$names): void
+ {
+ foreach ($names as $name) {
+ $this->conn->exec('DROP TABLE IF EXISTS ' . $name);
+ }
+ }
+
+ /**
+ * Resync Postgres IDENTITY sequences after fixtures with explicit IDs.
+ * No-op for sqlite and mysql (sqlite picks max+1 automatically; mysql
+ * advances AUTO_INCREMENT on explicit insert).
+ *
+ * @param array $tables map of table name to id column name
+ */
+ protected function syncSequences(array $tables): void
+ {
+ if ($this->driver !== 'pgsql') {
+ return;
+ }
+
+ foreach ($tables as $table => $idColumn) {
+ $this->conn->exec(
+ "SELECT setval(pg_get_serial_sequence('" . $table . "', '" . $idColumn . "'), "
+ . 'COALESCE((SELECT MAX(' . $idColumn . ') FROM ' . $table . '), 1))',
+ );
+ }
+ }
+}
diff --git a/tests/Database/DriverUnavailable.php b/tests/Database/DriverUnavailable.php
new file mode 100644
index 0000000..e823885
--- /dev/null
+++ b/tests/Database/DriverUnavailable.php
@@ -0,0 +1,15 @@
+ 'INTEGER PRIMARY KEY AUTOINCREMENT',
+ 'mysql' => 'INT AUTO_INCREMENT PRIMARY KEY',
+ 'pgsql' => 'INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY',
+ default => throw new InvalidArgumentException('Unsupported driver: ' . $driver),
+ };
+ }
+
+ public static function dateTime(string $driver): string
+ {
+ return match ($driver) {
+ 'sqlite', 'mysql' => 'DATETIME',
+ 'pgsql' => 'TIMESTAMP',
+ default => throw new InvalidArgumentException('Unsupported driver: ' . $driver),
+ };
+ }
+}
diff --git a/tests/DbTest.php b/tests/DbTest.php
index 36f24e5..b3ae5ee 100644
--- a/tests/DbTest.php
+++ b/tests/DbTest.php
@@ -6,33 +6,39 @@
use PDO;
use PHPUnit\Framework\Attributes\CoversClass;
-use PHPUnit\Framework\TestCase;
+use Respect\Relational\Database\DatabaseTestCase;
+use Respect\Relational\Database\Schema;
use function count;
-use function in_array;
use function is_array;
#[CoversClass(Db::class)]
-class DbTest extends TestCase
+class DbTest extends DatabaseTestCase
{
protected Db $object;
protected function setUp(): void
{
- if (!in_array('sqlite', PDO::getAvailableDrivers())) {
- $this->markTestSkipped('PDO_SQLITE is not available');
- }
+ parent::setUp();
- $db = new PDO('sqlite::memory:');
- $db->query('CREATE TABLE unit (testez INTEGER PRIMARY KEY AUTOINCREMENT, testa INT, testb VARCHAR(255))');
- $db->query("INSERT INTO unit (testa, testb) VALUES (10, 'abc')");
- $db->query("INSERT INTO unit (testa, testb) VALUES (20, 'def')");
- $db->query("INSERT INTO unit (testa, testb) VALUES (30, 'ghi')");
- $this->object = new Db($db);
+ $this->resetTables('unit');
+ $this->conn->exec((string) Sql::createTable('unit', [
+ 'testez ' . Schema::pkAuto($this->driver),
+ 'testa INT',
+ 'testb VARCHAR(255)',
+ ]));
+ $this->conn->exec("INSERT INTO unit (testa, testb) VALUES (10, 'abc')");
+ $this->conn->exec("INSERT INTO unit (testa, testb) VALUES (20, 'def')");
+ $this->conn->exec("INSERT INTO unit (testa, testb) VALUES (30, 'ghi')");
+ $this->object = new Db($this->conn);
}
public function testBasicStatement(): void
{
+ if ($this->driver !== 'sqlite') {
+ $this->markTestSkipped('sqlite_master is a SQLite-specific catalog table');
+ }
+
$this->assertEquals(
'unit',
$this->object->select('*')->from('sqlite_master')->fetch()->tbl_name,
diff --git a/tests/MapperTest.php b/tests/MapperTest.php
index 5789cb1..e65883d 100644
--- a/tests/MapperTest.php
+++ b/tests/MapperTest.php
@@ -10,25 +10,26 @@
use PDOException;
use PDOStatement;
use PHPUnit\Framework\Attributes\CoversClass;
-use PHPUnit\Framework\TestCase;
use ReflectionProperty;
use Respect\Data\EntityFactory;
use Respect\Data\Hydrators\PrestyledAssoc;
use Respect\Data\Styles;
+use Respect\Relational\Database\DatabaseTestCase;
+use Respect\Relational\Database\Schema;
use Throwable;
use TypeError;
+use function array_filter;
use function array_keys;
use function array_values;
use function count;
use function current;
use function date;
+use function str_contains;
#[CoversClass(Mapper::class)]
-class MapperTest extends TestCase
+class MapperTest extends DatabaseTestCase
{
- protected PDO $conn;
-
protected Mapper $mapper;
/** @var list> */
@@ -48,36 +49,51 @@ class MapperTest extends TestCase
protected function setUp(): void
{
- $conn = new PDO('sqlite::memory:');
+ parent::setUp();
+
+ $this->resetTables(
+ 'post_category',
+ 'category',
+ 'comment',
+ 'post',
+ 'author',
+ 'read_only_comment',
+ 'read_only_post',
+ 'read_only_author',
+ );
+
+ $pk = Schema::pkAuto($this->driver);
+ $datetime = Schema::dateTime($this->driver);
+ $conn = $this->conn;
$db = new Db($conn);
$conn->exec((string) Sql::createTable('post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . $pk,
'title VARCHAR(255)',
'text TEXT',
'author_id INTEGER',
]));
$conn->exec((string) Sql::createTable('author', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . $pk,
'name VARCHAR(255)',
]));
$conn->exec((string) Sql::createTable('comment', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . $pk,
'post_id INTEGER',
'text TEXT',
- 'datetime DATETIME',
+ 'datetime ' . $datetime,
]));
$conn->exec((string) Sql::createTable('category', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . $pk,
'name VARCHAR(255)',
'category_id INTEGER',
]));
$conn->exec((string) Sql::createTable('post_category', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . $pk,
'post_id INTEGER',
'category_id INTEGER',
]));
$conn->exec((string) Sql::createTable('read_only_author', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . $pk,
'name VARCHAR(255)',
'bio TEXT',
]));
@@ -122,11 +138,19 @@ protected function setUp(): void
->values([1, 'Alice', 'Alice bio'])
->exec();
+ $this->syncSequences([
+ 'post' => 'id',
+ 'author' => 'id',
+ 'comment' => 'id',
+ 'category' => 'id',
+ 'post_category' => 'id',
+ 'read_only_author' => 'id',
+ ]);
+
$mapper = new Mapper($conn, new PrestyledAssoc(new EntityFactory(
entityNamespace: 'Respect\\Relational\\',
)));
$this->mapper = $mapper;
- $this->conn = $conn;
}
public function testCreatingWithDbInstance(): void
@@ -222,6 +246,43 @@ public function testIgnoringLastInsertIdErrors(): void
$this->assertEquals('bar', $obj->name);
}
+ public function testCheckNewIdentityWrapsLastInsertIdInSavepointOnPgsql(): void
+ {
+ $execCalls = [];
+ $conn = $this->recordingPdoStub('pgsql', $execCalls);
+ $this->persistOneAuthor($conn);
+ $this->assertContains('SAVEPOINT respect_relational_lastid', $execCalls);
+ $this->assertContains('RELEASE SAVEPOINT respect_relational_lastid', $execCalls);
+ }
+
+ public function testCheckNewIdentityRollsBackSavepointWhenLastInsertIdFailsOnPgsql(): void
+ {
+ $execCalls = [];
+ $conn = $this->recordingPdoStub('pgsql', $execCalls, new PDOException('lastval not defined'));
+ $this->persistOneAuthor($conn);
+ $this->assertContains('SAVEPOINT respect_relational_lastid', $execCalls);
+ $this->assertContains('ROLLBACK TO SAVEPOINT respect_relational_lastid', $execCalls);
+ $this->assertNotContains('RELEASE SAVEPOINT respect_relational_lastid', $execCalls);
+ }
+
+ public function testCheckNewIdentityIssuesNoSavepointOnSqlite(): void
+ {
+ $execCalls = [];
+ $conn = $this->recordingPdoStub('sqlite', $execCalls);
+ $this->persistOneAuthor($conn);
+ $savepointCalls = array_filter($execCalls, static fn(string $sql): bool => str_contains($sql, 'SAVEPOINT'));
+ $this->assertSame([], $savepointCalls);
+ }
+
+ public function testCheckNewIdentityIssuesNoSavepointOnMysql(): void
+ {
+ $execCalls = [];
+ $conn = $this->recordingPdoStub('mysql', $execCalls);
+ $this->persistOneAuthor($conn);
+ $savepointCalls = array_filter($execCalls, static fn(string $sql): bool => str_contains($sql, 'SAVEPOINT'));
+ $this->assertSame([], $savepointCalls);
+ }
+
public function testRemovingUntrackedObject(): void
{
$comment = new Comment();
@@ -445,7 +506,7 @@ public function testAutoIncrementPersist(): void
$mapper->persist($entity, $mapper->category());
$mapper->flush();
$result = $this->query(
- 'select * from category where name="inserted"',
+ "select * from category where name='inserted'",
)->fetch(PDO::FETCH_OBJ);
$this->assertEquals(4, $result->id);
$this->assertEquals('inserted', $result->name);
@@ -467,7 +528,7 @@ public function testPassedIdentity(): void
$mapper->persist($comment, $mapper->comment());
$mapper->flush();
- $postId = $this->query('select id from post where title = 12345')
+ $postId = $this->query("select id from post where title = '12345'")
->fetchColumn(0);
$row = $this->query('select * from comment where post_id = ' . $postId)
@@ -874,7 +935,7 @@ public function testPersistPureEntityTreeDerivesForeignKey(): void
$this->mapper->persist($post, $this->mapper->post());
$this->mapper->flush();
- $row = $this->query('select * from post where title = "Pure Tree"')
+ $row = $this->query("select * from post where title = 'Pure Tree'")
->fetch(PDO::FETCH_ASSOC);
$this->assertIsArray($row);
$this->assertEquals(1, $row['author_id']);
@@ -925,7 +986,7 @@ public function testPersistCascadeSkipsNullChildRelation(): void
public function testReadOnlyNestedHydrationPostWithAuthor(): void
{
$this->conn->exec((string) Sql::createTable('read_only_post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'title VARCHAR(255)',
'text TEXT',
'read_only_author_id INTEGER',
@@ -949,13 +1010,13 @@ public function testReadOnlyNestedHydrationPostWithAuthor(): void
public function testReadOnlyThreeLevelHydration(): void
{
$this->conn->exec((string) Sql::createTable('read_only_post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'title VARCHAR(255)',
'text TEXT',
'read_only_author_id INTEGER',
]));
$this->conn->exec((string) Sql::createTable('read_only_comment', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'text TEXT',
'read_only_post_id INTEGER',
]));
@@ -986,7 +1047,7 @@ public function testReadOnlyThreeLevelHydration(): void
public function testReadOnlyInsertWithRelationCascade(): void
{
$this->conn->exec((string) Sql::createTable('read_only_post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'title VARCHAR(255)',
'text TEXT',
'read_only_author_id INTEGER',
@@ -1018,7 +1079,7 @@ public function testReadOnlyInsertWithRelationCascade(): void
public function testReadOnlyUpdateViaCollectionPkPreservesRelation(): void
{
$this->conn->exec((string) Sql::createTable('read_only_post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'title VARCHAR(255)',
'text TEXT',
'read_only_author_id INTEGER',
@@ -1056,7 +1117,7 @@ public function testReadOnlyUpdateViaCollectionPkPreservesRelation(): void
public function testReadOnlyUpdateChangesRelation(): void
{
$this->conn->exec((string) Sql::createTable('read_only_post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'title VARCHAR(255)',
'text TEXT',
'read_only_author_id INTEGER',
@@ -1093,7 +1154,7 @@ public function testReadOnlyUpdateChangesRelation(): void
public function testReadOnlyWithChangesAndPersistRoundTrip(): void
{
$this->conn->exec((string) Sql::createTable('read_only_post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'title VARCHAR(255)',
'text TEXT',
'read_only_author_id INTEGER',
@@ -1157,7 +1218,7 @@ public function testPersistPartialEntityRoundTrip(): void
public function testPersistPartialEntityOnGraph(): void
{
$this->conn->exec((string) Sql::createTable('read_only_post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'title VARCHAR(255)',
'text TEXT',
'read_only_author_id INTEGER',
@@ -1208,7 +1269,7 @@ public function testReadOnlyEntityInsertWithAutoIncrementPk(): void
$this->assertGreaterThan(0, $entity->id);
$result = $this->query(
- 'SELECT * FROM read_only_author WHERE name="Bob"',
+ "SELECT * FROM read_only_author WHERE name='Bob'",
)->fetch(PDO::FETCH_OBJ);
$this->assertSame('Bob', $result->name);
$this->assertSame('Bob bio', $result->bio);
@@ -1256,7 +1317,7 @@ public function testReadOnlyDeleteAndRefetch(): void
public function testMixedMutableAuthorReadOnlyPost(): void
{
$this->conn->exec((string) Sql::createTable('read_only_post', [
- 'id INTEGER PRIMARY KEY',
+ 'id ' . Schema::pkAuto($this->driver),
'title VARCHAR(255)',
'text TEXT',
'read_only_author_id INTEGER',
@@ -1280,7 +1341,7 @@ public function testMixedMutableAuthorReadOnlyPost(): void
$this->assertGreaterThan(0, $readonlyPost->id);
// Verify both persisted
- $authorRow = $this->query('SELECT * FROM author WHERE name="Mutable Author"')
+ $authorRow = $this->query("SELECT * FROM author WHERE name='Mutable Author'")
->fetch(PDO::FETCH_OBJ);
$postRow = $this->query('SELECT * FROM read_only_post WHERE id=' . $readonlyPost->id)
->fetch(PDO::FETCH_OBJ);
@@ -1312,4 +1373,47 @@ private function query(string $sql): PDOStatement
return $stmt;
}
+
+ /** @param list $execCalls populated by reference with each exec() call */
+ private function recordingPdoStub(
+ string $driver,
+ array &$execCalls,
+ PDOException|null $lastInsertIdError = null,
+ ): PDO {
+ $execCalls = [];
+ $conn = $this->createStub(PDO::class);
+ $conn->method('getAttribute')->willReturn($driver);
+ $conn->method('inTransaction')->willReturn(true);
+ $conn->method('beginTransaction')->willReturn(true);
+ $conn->method('commit')->willReturn(true);
+
+ $stmt = $this->createStub(PDOStatement::class);
+ $stmt->method('execute')->willReturn(true);
+ $conn->method('prepare')->willReturn($stmt);
+
+ $conn->method('exec')->willReturnCallback(static function (string $sql) use (&$execCalls): int {
+ $execCalls[] = $sql;
+
+ return 0;
+ });
+
+ if ($lastInsertIdError !== null) {
+ $conn->method('lastInsertId')->willThrowException($lastInsertIdError);
+ } else {
+ $conn->method('lastInsertId')->willReturn('1');
+ }
+
+ return $conn;
+ }
+
+ private function persistOneAuthor(PDO $conn): void
+ {
+ $mapper = new Mapper($conn, new PrestyledAssoc(new EntityFactory(
+ entityNamespace: 'Respect\\Relational\\',
+ )));
+ $author = new Author();
+ $author->name = 'Pat';
+ $mapper->persist($author, $mapper->author());
+ $mapper->flush();
+ }
}