From 489ce6556c68d68628327dcd55dd30658c3ba22f 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 (unfinished) --- lib/Pgsql.php | 136 +++++++++++++++++++++++++++++++++++++ lib/Pgsql/Options.php | 16 +++++ lib/Pgsql/Statement.php | 71 +++++++++++++++++++ test/DbTestCase.php | 3 +- test/Pgsql/OptionsTest.php | 39 +++++++++++ test/Pgsql/PgsqlDbTest.php | 54 +++++++++++++++ test/src/Config.php | 15 ++++ 7 files changed, 332 insertions(+), 2 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/lib/Pgsql.php b/lib/Pgsql.php new file mode 100644 index 0000000..e328310 --- /dev/null +++ b/lib/Pgsql.php @@ -0,0 +1,136 @@ +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()); + } + } + + public function makeBinaryParam(?string $binaryStr, ?int $length = null): ?string + { + // binary values can be inserted directly when using MySQL + return $binaryStr; + } + + /** + * @internal + */ + public static function getError(string $msg, array $error, string $sql = '', array $params = []): 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($msg, $code, $details, $sqlState, $sql, $params); + } + + /** + * 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(), $sql, $params); + } + } catch (\PDOException $e) { + throw $this->getError('Failed to prepare statement', $this->conn->errorInfo(), $sql, $params); + } + + return new Statement($stmt, $this->usedPrepare, $sql, $params); + } + + /** + * 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($this->params)) { + throw Pgsql::getError('Failed to execute prepared statement', $this->stmt->errorInfo(), $this->query, $this->params); + } + } catch (PDOException $e) { + throw Pgsql::getError('Failed to execute prepared statement', $this->stmt->errorInfo(), $this->query, $this->params); + } + + $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) { + /** @var array|false $row */ + while ($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 c72c062..e9088d2 100644 --- a/test/DbTestCase.php +++ b/test/DbTestCase.php @@ -94,10 +94,9 @@ public function testException(PeachySql $peachySql): void } catch (SqlException $e) { $this->assertSame($badQuery, $e->query); $this->assertSame([], $e->params); - $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()); } } 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..b5c47ed --- /dev/null +++ b/test/Pgsql/PgsqlDbTest.php @@ -0,0 +1,54 @@ +getPgsqlDsn(), $c->getPgsqlUser(), $c->getPgsqlPassword()); + + 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, + isDisabled BOOLEAN NOT NULL, + uuid bytea NULL + )"; + + $db->query("DROP TABLE IF EXISTS Users"); + $db->query($sql); + } +} 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';