From 3855e480a0e66c7e497cf3a3a02c16dfe7b124fa Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Mon, 27 Apr 2026 15:22:05 -0300 Subject: [PATCH] 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. --- .env.example | 15 ++ .github/workflows/ci.yml | 67 +++++++- CONTRIBUTING.md | 24 +++ composer.json | 8 + docker-compose.yml | 29 ++++ phpunit.xml.dist | 7 +- src/Mapper.php | 25 ++- tests/ConnectionFactoryTest.php | 84 ++++++++++ tests/Database/ConnectionFactory.php | 100 +++++++++++ tests/Database/DatabaseTestCase.php | 56 +++++++ tests/Database/DriverUnavailable.php | 15 ++ tests/Database/InvalidDriverConfiguration.php | 11 ++ tests/Database/Schema.php | 29 ++++ tests/DbTest.php | 30 ++-- tests/MapperTest.php | 158 +++++++++++++++--- 15 files changed, 615 insertions(+), 43 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml create mode 100644 tests/ConnectionFactoryTest.php create mode 100644 tests/Database/ConnectionFactory.php create mode 100644 tests/Database/DatabaseTestCase.php create mode 100644 tests/Database/DriverUnavailable.php create mode 100644 tests/Database/InvalidDriverConfiguration.php create mode 100644 tests/Database/Schema.php 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(); + } }