Skip to content

Commit

Permalink
Implement PostgreSQL support with PDO (unfinished)
Browse files Browse the repository at this point in the history
  • Loading branch information
theodorejb committed Oct 15, 2024
1 parent 14a161f commit a1fd343
Show file tree
Hide file tree
Showing 7 changed files with 334 additions and 2 deletions.
136 changes: 136 additions & 0 deletions lib/Pgsql.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace PeachySQL;

use PDO;
use PeachySQL\Pgsql\Options;
use PeachySQL\Pgsql\Statement;
use PeachySQL\QueryBuilder\Insert;

/**
* Implements the standard PeachySQL features for PostgreSQL (using PDO)
*/
class Pgsql extends PeachySql
{
private PDO $conn;
private bool $usedPrepare;

public function __construct(PDO $connection, ?Options $options = null)
{
$this->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());
}
}
16 changes: 16 additions & 0 deletions lib/Pgsql/Options.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace PeachySQL\Pgsql;

use PeachySQL\BaseOptions;

/**
* Handles PostgreSQL-specific options
*/
class Options extends BaseOptions
{
// https://stackoverflow.com/questions/6581573/what-are-the-max-number-of-allowable-parameters-per-database-provider-type
public int $maxBoundParams = 65_535; // 2 ** 16 - 1;
}
75 changes: 75 additions & 0 deletions lib/Pgsql/Statement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace PeachySQL\Pgsql;

use PeachySQL\BaseStatement;
use PeachySQL\Pgsql;
use PDO;
use PDOException;
use PDOStatement;

class Statement extends BaseStatement
{
private ?PDOStatement $stmt;
private array $params;

public function __construct(PDOStatement $stmt, bool $usedPrepare, array $params)
{
parent::__construct($usedPrepare);
$this->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;
}
}
3 changes: 1 addition & 2 deletions test/DbTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}

Expand Down
39 changes: 39 additions & 0 deletions test/Pgsql/OptionsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace PeachySQL\Test\Pgsql;

use PeachySQL\Pgsql\Options;
use PHPUnit\Framework\TestCase;

/**
* Tests base as well as PostgreSQL-specific configuration settings
*/
class OptionsTest extends TestCase
{
public function testEscapeIdentifier(): void
{
$options = new Options();
$actual = $options->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);
}
}
52 changes: 52 additions & 0 deletions test/Pgsql/PgsqlDbTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace PeachySQL\Test\Pgsql;

use PeachySQL\Pgsql;
use PeachySQL\Test\DbTestCase;
use PeachySQL\Test\src\App;
use PDO;

/**
* @group pgsql
*/
class PgsqlDbTest extends DbTestCase
{
private static ?Pgsql $db = null;

protected function getExpectedBadSyntaxCode(): int
{
return 7;
}

public static function dbProvider(): Pgsql
{
if (!self::$db) {
$c = App::$config;
$pdo = new PDO($c->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);
}
}
15 changes: 15 additions & 0 deletions test/src/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down

0 comments on commit a1fd343

Please sign in to comment.