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 14, 2024
1 parent 32185e7 commit 489ce65
Show file tree
Hide file tree
Showing 7 changed files with 332 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 $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());
}
}
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;
}
71 changes: 71 additions & 0 deletions lib/Pgsql/Statement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?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;

public function __construct(PDOStatement $stmt, bool $usedPrepare, string $query, array $params)
{
parent::__construct($usedPrepare, $query, $params);
$this->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

Check failure on line 44 in lib/Pgsql/Statement.php

View workflow job for this annotation

GitHub Actions / Run tests on 8.3

MixedReturnTypeCoercion

lib/Pgsql/Statement.php:44:36: MixedReturnTypeCoercion: The declared return type 'Generator<int, array<array-key, mixed>, mixed, mixed>' for PeachySQL\Pgsql\Statement::getIterator is more specific than the inferred return type 'Generator<int, non-empty-mixed, mixed, void>' (see https://psalm.dev/197)
{
if ($this->stmt !== null) {
/** @var array|false $row */
while ($row = $this->stmt->fetch(PDO::FETCH_ASSOC)) {

Check failure on line 48 in lib/Pgsql/Statement.php

View workflow job for this annotation

GitHub Actions / Run tests on 8.3

MixedAssignment

lib/Pgsql/Statement.php:48:20: MixedAssignment: Unable to determine the type that $row is being assigned to (see https://psalm.dev/032)
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 @@ -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());
}
}

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);
}
}
54 changes: 54 additions & 0 deletions test/Pgsql/PgsqlDbTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?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(): array
{
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 489ce65

Please sign in to comment.