diff --git a/lib/Pgsql.php b/lib/Pgsql.php new file mode 100644 index 0000000..a3bb946 --- /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 $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()); + } + } catch (\PDOException $e) { + throw $this->getError('Failed to prepare statement', $this->conn->errorInfo()); + } + + return new Statement($stmt, $this->usedPrepare, $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; + $this->params = $params; + } + + 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()); + } + } 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..2333b67 100644 --- a/test/DbTestCase.php +++ b/test/DbTestCase.php @@ -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()); } } 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..495022c --- /dev/null +++ b/test/Pgsql/PgsqlDbTest.php @@ -0,0 +1,52 @@ +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';