From 2ac71e14fdcafad08c059ae89aa03cbc6d0d4d1b Mon Sep 17 00:00:00 2001 From: Theodore Brown Date: Mon, 14 Oct 2024 00:02:15 -0500 Subject: [PATCH] Implement PostgreSQL support with PDO --- .github/workflows/php.yml | 2 +- composer.json | 4 +- lib/Pgsql.php | 151 +++++++++++++++++++++++++++++++++ lib/Pgsql/Options.php | 16 ++++ lib/Pgsql/Statement.php | 73 ++++++++++++++++ test/DbTestCase.php | 46 +++++++--- test/Mysql/MysqlDbTest.php | 2 +- test/Pgsql/OptionsTest.php | 39 +++++++++ test/Pgsql/PgsqlDbTest.php | 54 ++++++++++++ test/SqlServer/MssqlDbTest.php | 2 +- test/src/Config.php | 15 ++++ 11 files changed, 386 insertions(+), 18 deletions(-) create mode 100644 lib/Pgsql.php create mode 100644 lib/Pgsql/Options.php create mode 100644 lib/Pgsql/Statement.php create mode 100644 test/Pgsql/OptionsTest.php create mode 100644 test/Pgsql/PgsqlDbTest.php diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 232463c..9bca569 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -42,6 +42,6 @@ jobs: if: ${{ matrix.php == '8.3' }} - name: Run PHPUnit - run: composer test-without-mssql + run: composer test-mysql env: DB_PORT: ${{ job.services.mysql.ports[3306] }} diff --git a/composer.json b/composer.json index b77d387..94fa008 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,9 @@ "scripts": { "analyze": "psalm", "test": "phpunit", - "test-mssql": "phpunit --exclude-group mysql", + "test-mssql": "phpunit --exclude-group mysql,pgsql", + "test-mysql": "phpunit --exclude-group mssql,pgsql", + "test-pgsql": "phpunit --exclude-group mssql,mysql", "test-without-mssql": "phpunit --exclude-group mssql" } } diff --git a/lib/Pgsql.php b/lib/Pgsql.php new file mode 100644 index 0000000..287a5fa --- /dev/null +++ b/lib/Pgsql.php @@ -0,0 +1,151 @@ +conn = $connection; + $this->usedPrepare = true; + + if ($options === null) { + $options = new Options(); + } + + $this->options = $options; + } + + /** + * Begins a transaction + * @throws SqlException if an error occurs + */ + public function begin(): void + { + if (!$this->conn->beginTransaction()) { + throw $this->getError('Failed to begin transaction', $this->conn->errorInfo()); + } + } + + /** + * Commits a transaction begun with begin() + * @throws SqlException if an error occurs + */ + public function commit(): void + { + if (!$this->conn->commit()) { + throw $this->getError('Failed to commit transaction', $this->conn->errorInfo()); + } + } + + /** + * Rolls back a transaction begun with begin() + * @throws SqlException if an error occurs + */ + public function rollback(): void + { + if (!$this->conn->rollback()) { + throw $this->getError('Failed to roll back transaction', $this->conn->errorInfo()); + } + } + + final public function makeBinaryParam(?string $binaryStr, ?int $length = null): array + { + return [$binaryStr, $length]; + } + + /** + * @internal + */ + public static function getError(string $message, array $error): SqlException + { + /** @var array{0: string, 1: int|null, 2: string|null} $error */ + $code = $error[1] ?? 0; + $details = $error[2] ?? ''; + $sqlState = $error[0]; + + return new SqlException($message, $code, $details, $sqlState); + } + + /** + * Returns a prepared statement which can be executed multiple times + * @throws SqlException if an error occurs + */ + public function prepare(string $sql, array $params = []): Statement + { + try { + if (!$stmt = $this->conn->prepare($sql)) { + throw $this->getError('Failed to prepare statement', $this->conn->errorInfo()); + } + + $i = 0; + /** @psalm-suppress MixedAssignment */ + foreach ($params as &$param) { + $i++; + + if (is_bool($param)) { + $stmt->bindParam($i, $param, PDO::PARAM_BOOL); + } elseif (is_int($param)) { + $stmt->bindParam($i, $param, PDO::PARAM_INT); + } elseif (is_array($param)) { + $stmt->bindParam($i, $param[0], PDO::PARAM_LOB); + } else { + $stmt->bindParam($i, $param, PDO::PARAM_STR); + } + } + } catch (\PDOException $e) { + throw $this->getError('Failed to prepare statement', $this->conn->errorInfo()); + } + + return new Statement($stmt, $this->usedPrepare); + } + + /** + * Prepares and executes a single query with bound parameters + */ + public function query(string $sql, array $params = []): Statement + { + $this->usedPrepare = false; + $stmt = $this->prepare($sql, $params); + $this->usedPrepare = true; + $stmt->execute(); + return $stmt; + } + + /** + * Performs a single bulk insert query + */ + protected function insertBatch(string $table, array $colVals, int $identityIncrement = 1): BulkInsertResult + { + $sqlParams = (new Insert($this->options))->buildQuery($table, $colVals); + $result = $this->query($sqlParams->sql, $sqlParams->params); + + try { + $lastId = (int) $this->conn->lastInsertId(); + } catch (\PDOException $e) { + $lastId = 0; + } + + if ($lastId) { + $firstId = $lastId - $identityIncrement * (count($colVals) -1); + $ids = range($firstId, $lastId, $identityIncrement); + } else { + $ids = []; + } + + return new BulkInsertResult($ids, $result->getAffected()); + } +} diff --git a/lib/Pgsql/Options.php b/lib/Pgsql/Options.php new file mode 100644 index 0000000..a113850 --- /dev/null +++ b/lib/Pgsql/Options.php @@ -0,0 +1,16 @@ +stmt = $stmt; + } + + public function execute(): void + { + if ($this->stmt === null) { + throw new \Exception('Cannot execute closed statement'); + } + + try { + if (!$this->stmt->execute()) { + throw Pgsql::getError('Failed to execute prepared statement', $this->stmt->errorInfo()); + } + } catch (PDOException $e) { + throw Pgsql::getError('Failed to execute prepared statement', $this->stmt->errorInfo()); + } + + $this->affected = $this->stmt->rowCount(); + + if (!$this->usedPrepare && $this->stmt->columnCount() === 0) { + $this->close(); // no results, so statement can be closed + } + } + + public function getIterator(): \Generator + { + if ($this->stmt !== null) { + while ( + /** @var array|false $row */ + $row = $this->stmt->fetch(PDO::FETCH_ASSOC) + ) { + yield $row; + } + + if (!$this->usedPrepare) { + $this->close(); + } + } + } + + /** + * Closes the prepared statement and deallocates the statement handle. + * @throws \Exception if the statement has already been closed + */ + public function close(): void + { + if ($this->stmt === null) { + throw new \Exception('Statement has already been closed'); + } + + $this->stmt->closeCursor(); + $this->stmt = null; + } +} diff --git a/test/DbTestCase.php b/test/DbTestCase.php index cd7e4a2..1163231 100644 --- a/test/DbTestCase.php +++ b/test/DbTestCase.php @@ -52,16 +52,16 @@ public function testTransactions(): void 'name' => 'George McFly', 'dob' => '1938-04-01', 'weight' => 133.8, - 'isDisabled' => true, + 'is_disabled' => true, 'uuid' => $peachySql->makeBinaryParam(Uuid::uuid4()->getBytes(), 16), ]; $id = $peachySql->insertRow($this->table, $colVals)->id; - $sql = "SELECT user_id, isDisabled FROM {$this->table} WHERE user_id = ?"; + $sql = "SELECT user_id, is_disabled FROM {$this->table} WHERE user_id = ?"; $result = $peachySql->query($sql, [$id]); - $this->assertSame(-1, $result->getAffected()); - $this->assertSame(['user_id' => $id, 'isDisabled' => 1], $result->getFirst()); // the row should be selectable + $this->assertContains($result->getAffected(), [-1, 1]); // 1 for PostgreSQL + $this->assertEquals(['user_id' => $id, 'is_disabled' => 1], $result->getFirst()); // the row should be selectable $peachySql->rollback(); // cancel the transaction $sameRow = $peachySql->query($sql, [$id])->getFirst(); @@ -85,10 +85,9 @@ public function testException(): void $peachySql->query($badQuery); // should throw exception $this->fail('Bad query failed to throw exception'); } catch (SqlException $e) { - $this->assertSame('42000', $e->getSqlState()); + $this->assertContains($e->getSqlState(), ['42000', '42601']); $this->assertSame($this->getExpectedBadSyntaxCode(), $e->getCode()); $this->assertStringContainsString(' syntax ', $e->getMessage()); - $this->assertStringContainsString(" near '", $e->getMessage()); } } @@ -96,8 +95,8 @@ public function testIteratorQuery(): void { $peachySql = static::dbProvider(); $colVals = [ - ['name' => 'Martin S. McFly', 'dob' => '1968-06-20', 'weight' => 140.7, 'isDisabled' => true, 'uuid' => Uuid::uuid4()->getBytes()], - ['name' => 'Emmett L. Brown', 'dob' => '1920-01-01', 'weight' => 155.4, 'isDisabled' => false, 'uuid' => null], + ['name' => 'Martin S. McFly', 'dob' => '1968-06-20', 'weight' => 140.7, 'is_disabled' => true, 'uuid' => Uuid::uuid4()->getBytes()], + ['name' => 'Emmett L. Brown', 'dob' => '1920-01-01', 'weight' => 155.4, 'is_disabled' => false, 'uuid' => null], ]; $insertColVals = []; @@ -116,11 +115,17 @@ public function testIteratorQuery(): void foreach ($iterator as $row) { unset($row['user_id']); - $row['isDisabled'] = (bool)$row['isDisabled']; + $row['is_disabled'] = (bool)$row['is_disabled']; + + if ($row['uuid'] !== null && !is_string($row['uuid'])) { + /** @psalm-suppress InvalidArgument */ + $row['uuid'] = stream_get_contents($row['uuid']); // needed for PostgreSQL + } + $colValsCompare[] = $row; } - $this->assertSame($colVals, $colValsCompare); + $this->assertEquals($colVals, $colValsCompare); // use a prepared statement to update both of the rows $sql = "UPDATE {$this->table} SET name = ? WHERE user_id = ?"; @@ -163,7 +168,7 @@ public function testInsertBulk(): void 'name' => 'name' . $i, 'dob' => $dob->format('Y-m-d'), 'weight' => round(rand(1001, 2899) / 10, 1), - 'isDisabled' => 0, + 'is_disabled' => 0, 'uuid' => Uuid::uuid4()->getBytes(), ]; } @@ -187,7 +192,15 @@ public function testInsertBulk(): void $rows = $peachySql->selectFrom("SELECT {$columns} FROM {$this->table}") ->where(['user_id' => $ids])->query()->getAll(); - $this->assertSame($colVals, $rows); + /** @var array{uuid: string|resource} $row */ + foreach ($rows as &$row) { + if (!is_string($row['uuid'])) { + /** @psalm-suppress InvalidArgument */ + $row['uuid'] = stream_get_contents($row['uuid']); // needed for PostgreSQL + } + } + + $this->assertEquals($colVals, $rows); // update the inserted rows $numUpdated = $peachySql->updateRows($this->table, ['name' => 'updated'], ['user_id' => $ids]); @@ -198,9 +211,14 @@ public function testInsertBulk(): void $userId = $ids[0]; $set = ['uuid' => $peachySql->makeBinaryParam($newUuid)]; $peachySql->updateRows($this->table, $set, ['user_id' => $userId]); - /** @var array{uuid: string} $updatedRow */ + /** @var array{uuid: string|resource} $updatedRow */ $updatedRow = $peachySql->selectFrom("SELECT uuid FROM {$this->table}") ->where(['user_id' => $userId])->query()->getFirst(); + + if (!is_string($updatedRow['uuid'])) { + $updatedRow['uuid'] = stream_get_contents($updatedRow['uuid']); // needed for PostgreSQL + } + $this->assertSame($newUuid, $updatedRow['uuid']); // delete the inserted rows @@ -220,7 +238,7 @@ public function testEmptyBulkInsert(): void public function testSelectFromBinding(): void { $peachySql = static::dbProvider(); - $row = ['name' => 'Test User', 'dob' => '2000-01-01', 'weight' => 123, 'isDisabled' => true]; + $row = ['name' => 'Test User', 'dob' => '2000-01-01', 'weight' => 123, 'is_disabled' => true]; $id = $peachySql->insertRow($this->table, $row)->id; $result = $peachySql->select(new SqlParams("SELECT name, ? AS bound FROM {$this->table}", ['value'])) diff --git a/test/Mysql/MysqlDbTest.php b/test/Mysql/MysqlDbTest.php index 52fd52f..18477d8 100644 --- a/test/Mysql/MysqlDbTest.php +++ b/test/Mysql/MysqlDbTest.php @@ -53,7 +53,7 @@ private static function createTestTable(Mysql $db): void name VARCHAR(50) NOT NULL, dob DATE NOT NULL, weight FLOAT NOT NULL, - isDisabled BOOLEAN NOT NULL, + is_disabled BOOLEAN NOT NULL, uuid BINARY(16) NULL )"; diff --git a/test/Pgsql/OptionsTest.php b/test/Pgsql/OptionsTest.php new file mode 100644 index 0000000..2886d15 --- /dev/null +++ b/test/Pgsql/OptionsTest.php @@ -0,0 +1,39 @@ +escapeIdentifier('Test"Identifier'); + $this->assertSame('"Test""Identifier"', $actual); + + try { + $options->escapeIdentifier(''); // should throw exception + $this->fail('escapeIdentifier failed to throw expected exception'); + } catch (\InvalidArgumentException $e) { + $this->assertSame('Identifier cannot be blank', $e->getMessage()); + } + } + + public function testBuildPagination(): void + { + $options = new Options(); + + $page1 = $options->buildPagination(25, 0); + $this->assertSame('LIMIT 25 OFFSET 0', $page1); + + $page3 = $options->buildPagination(100, 200); + $this->assertSame('LIMIT 100 OFFSET 200', $page3); + } +} diff --git a/test/Pgsql/PgsqlDbTest.php b/test/Pgsql/PgsqlDbTest.php new file mode 100644 index 0000000..9d0107c --- /dev/null +++ b/test/Pgsql/PgsqlDbTest.php @@ -0,0 +1,54 @@ +getPgsqlDsn(), $c->getPgsqlUser(), $c->getPgsqlPassword(), [ + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + self::$db = new Pgsql($pdo); + self::createTestTable(self::$db); + } + + return self::$db; + } + + private static function createTestTable(Pgsql $db): void + { + $sql = " + CREATE TABLE Users ( + user_id SERIAL PRIMARY KEY, + name VARCHAR(50) NOT NULL, + dob DATE NOT NULL, + weight REAL NOT NULL, + is_disabled BOOLEAN NOT NULL, + uuid bytea NULL + )"; + + $db->query("DROP TABLE IF EXISTS Users"); + $db->query($sql); + } +} diff --git a/test/SqlServer/MssqlDbTest.php b/test/SqlServer/MssqlDbTest.php index e3b0f40..4fb8ef6 100644 --- a/test/SqlServer/MssqlDbTest.php +++ b/test/SqlServer/MssqlDbTest.php @@ -60,7 +60,7 @@ private static function createTestTable(SqlServer $db): void name VARCHAR(50) NOT NULL, dob DATE NOT NULL, weight FLOAT NOT NULL, - isDisabled BIT NOT NULL, + is_disabled BIT NOT NULL, uuid BINARY(16) NULL )"; diff --git a/test/src/Config.php b/test/src/Config.php index 68dd972..0cc4861 100644 --- a/test/src/Config.php +++ b/test/src/Config.php @@ -29,6 +29,21 @@ public function getMysqlDatabase(): string return 'PeachySQL'; } + public function getPgsqlDsn(): string + { + return "pgsql:host=localhost;dbname=PeachySQL"; + } + + public function getPgsqlUser(): string + { + return 'postgres'; + } + + public function getPgsqlPassword(): string + { + return ''; + } + public function getSqlsrvServer(): string { return '(local)\SQLEXPRESS';