Skip to content

Commit

Permalink
Implement PostgreSQL support with PDO
Browse files Browse the repository at this point in the history
  • Loading branch information
theodorejb committed Oct 17, 2024
1 parent 14a161f commit 2ac71e1
Show file tree
Hide file tree
Showing 11 changed files with 386 additions and 18 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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] }}
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
151 changes: 151 additions & 0 deletions lib/Pgsql.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?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());
}
}

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());
}
}
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;
}
73 changes: 73 additions & 0 deletions lib/Pgsql/Statement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?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)
{
parent::__construct($usedPrepare);
$this->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;
}
}
46 changes: 32 additions & 14 deletions test/DbTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -85,19 +85,18 @@ 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());
}
}

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 = [];
Expand All @@ -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 = ?";
Expand Down Expand Up @@ -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(),
];
}
Expand All @@ -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]);
Expand All @@ -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
Expand All @@ -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']))
Expand Down
2 changes: 1 addition & 1 deletion test/Mysql/MysqlDbTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
)";

Expand Down
Loading

0 comments on commit 2ac71e1

Please sign in to comment.