Skip to content

Instantly share code, notes, and snippets.

@nandordudas
Last active February 12, 2025 12:25
Show Gist options
  • Save nandordudas/22f11a6ed4649de9460d1bf1be2ebab3 to your computer and use it in GitHub Desktop.
Save nandordudas/22f11a6ed4649de9460d1bf1be2ebab3 to your computer and use it in GitHub Desktop.
<?php
declare(strict_types=1);
error_reporting(E_ALL);
ini_set('display_errors', '1');
// [INFO] PHP 7.2
if (PHP_VERSION_ID < 80000) {
interface Stringable
{
public function __toString(): string;
}
}
function executeUsersQuery(Statement $statement)
{
$usersQuery = <<<SQL
SELECT
users.*
FROM
users
WHERE 1=1
AND users.name = :name
GROUP BY
users.name
ORDER BY
users.name DESC
LIMIT 10
OFFSET 0
SQL; // [WARN] PHP 7.2 does not support indentation
return $statement
->prepare($usersQuery)
->bindParameter(QueryParameter::string('name', 'John'))
// ->bindParameter(QueryParameter::string('name', 'John')) // [INFO] throws duplicate parameter exception
->execute();
}
final class User
{
public static function create(array $value): static
{
return new static($value);
}
/** @var string */
public $id;
/** @var string */
public $name;
public function __construct(array $value)
{
if (!isset($value['id']) || !isset($value['name']))
throw new InvalidArgumentException("User id and name are required");
$this->id = $value['id'];
$this->name = $value['name'];
$this->validate();
}
private function validate() {
if (empty($this->id))
throw new InvalidArgumentException("User id is required");
if (empty($this->name))
throw new InvalidArgumentException("User name is required");
}
}
$mapUser = static function (array $value) {
return User::create($value);
};
try {
$pdoAdapterConfig = PDOAdapterConfig::create([
'driver' => 'mysql',
'host' => 'db',
'port' => 3306,
'database' => 'mariadb',
'username' => 'mariadb',
'password' => 'mariadb',
]);
$pdoAdapter = PDOAdapter::create($pdoAdapterConfig);
var_dump('Is connected: ' . ['false', 'true'][$pdoAdapter->isConnected()]);
/** @var ResultSet */
$users = $pdoAdapter->transaction(function (Statement $statement) {
var_dump($this instanceof PDO); // true
return executeUsersQuery($statement);
});
/** @var User $user */
foreach ($users->map($mapUser) as $user)
var_dump($user->name);
/** @var User $user */
foreach (executeUsersQuery($pdoAdapter->statement)->map($mapUser) as $user)
var_dump($user);
$pdoAdapter->disconnect();
var_dump('Is connected: ' . ['false', 'true'][$pdoAdapter->isConnected()]);
} catch (Throwable $error) {
echo $error->getMessage() . PHP_EOL . $error->getTraceAsString() . PHP_EOL;
}
<?php
declare(strict_types=1);
namespace Database;
use PDO;
use RuntimeException;
use Throwable;
final class PDOAdapter
{
public static function create(PDOAdapterConfig $config): self
{
return new self($config);
}
private const PDO_OPTIONS = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
/** @var Statement */
public $statement;
/** @var ?PDO */
private $pdo;
private function __construct(PDOAdapterConfig $config)
{
$this->pdo = new PDO((string) $config, $config->username, $config->password, self::PDO_OPTIONS);
$this->statement = new Statement($this->pdo);
}
public function transaction(callable $callback)
{
if ($this->pdo === null)
throw new RuntimeException("Connection is not established");
return TransactionManager::create($this->pdo, $this->statement)
->transaction($callback);
}
public function isConnected(): bool
{
try {
return $this->pdo !== null && $this->pdo->query('SELECT 1') !== false;
} catch (Throwable $error) {
return false;
}
}
public function disconnect(): void
{
if ($this->pdo === null)
return;
if ($this->pdo->inTransaction())
$this->pdo->rollBack();
$this->pdo = null;
}
public function __destruct()
{
$this->statement = null;
$this->disconnect();
}
}
<?php
declare(strict_types=1);
namespace Database;
use InvalidArgumentException;
use PDO;
if (PHP_VERSION_ID < 80000) {
interface Stringable
{
public function __toString(): string;
}
}
final class PDOAdapterConfig implements Stringable
{
private const MIN_PORT = 1;
private const MAX_PORT = 65535;
public static function create(array $value): self
{
foreach (['driver', 'host', 'port', 'database', 'username', 'password'] as $key) {
if (!isset($value[$key]))
throw new InvalidArgumentException("{$key} is required");
}
return new static(
$value['driver'],
$value['host'],
$value['port'],
$value['database'],
$value['username'],
$value['password']
);
}
public static function createFromEnv(): self
{
foreach (['DB_DRIVER', 'DB_HOST', 'DB_PORT', 'DB_DATABASE', 'DB_USER', 'DB_PASSWORD', 'DB_CHARSET'] as $key) {
if (!isset($_ENV[$key]) || empty($_ENV[$key]))
throw new InvalidArgumentException("Environment variable {$key} is required");
}
return new static(
$_ENV['DB_DRIVER'],
$_ENV['DB_HOST'],
$_ENV['DB_PORT'],
$_ENV['DB_DATABASE'],
$_ENV['DB_USER'],
$_ENV['DB_PASSWORD'],
$_ENV['DB_CHARSET']
);
}
/** @var string */
public $driver;
/** @var string */
public $host;
/** @var int */
public $port;
/** @var string */
public $database;
/** @var string */
public $username;
/** @var string */
public $password;
/** @var string */
public $charset;
public function __construct(
string $driver,
string $host,
int $port,
string $database,
string $username,
string $password,
string $charset = 'utf8mb4'
) {
$this->validate($driver, $host, $port, $database, $username);
$this->driver = $driver;
$this->host = $host;
$this->port = $port;
$this->database = $database;
$this->username = $username;
$this->password = $password;
$this->charset = $charset;
}
public function __toString(): string
{
if ($this->driver === 'sqlite')
return "{$this->driver}:{$this->database}";
return "{$this->driver}:host={$this->host};port={$this->port};dbname={$this->database};charset={$this->charset}";
}
private function validate(
string $driver,
string $host,
int $port,
string $database,
string $username
): void {
if (!in_array($driver, PDO::getAvailableDrivers(), true))
throw new InvalidArgumentException("Unsupported database driver: {$driver}");
if (empty($database))
throw new InvalidArgumentException("Database name is required");
if ($driver === 'sqlite' && !is_writable($database))
throw new InvalidArgumentException("Database file does not exist or is not writable: {$database}");
if ($driver === 'mysql') {
if (empty($host))
throw new InvalidArgumentException("Database host is required");
if ($port < self::MIN_PORT || $port > self::MAX_PORT)
throw new InvalidArgumentException("Invalid database port: {$port}");
if (empty($username))
throw new InvalidArgumentException("Database username is required");
}
}
}
<?php
declare(strict_types=1);
namespace Database;
use InvalidArgumentException;
use PDO;
final class QueryParameter
{
public static function string(string $name, string $value): self
{
return new static($name, $value, PDO::PARAM_STR);
}
public static function int(string $name, int $value): self
{
return new static($name, $value, PDO::PARAM_INT);
}
public static function bool(string $name, bool $value): self
{
return new static($name, $value, PDO::PARAM_BOOL);
}
public static function null(string $name): self
{
return new static($name, null, PDO::PARAM_NULL);
}
/** @var string */
public $name;
/** @var mixed */
public $value;
/** @var int */
public $type;
public function __construct(
string $name,
$value,
int $type
) {
$this->validate($name, $value, $type);
$this->name = $name;
$this->value = $value;
$this->type = $type;
}
private function validate(
string $name,
$value,
int $type
): void {
if (empty($name))
throw new InvalidArgumentException("Parameter name is required");
if (empty($value))
throw new InvalidArgumentException("Parameter value is required");
switch ($type) {
case PDO::PARAM_NULL:
if (!is_null($value))
throw new InvalidArgumentException("Null parameter expected, got: " . gettype($value));
break;
case PDO::PARAM_INT:
if (!is_int($value))
throw new InvalidArgumentException("Integer parameter expected, got: " . gettype($value));
break;
case PDO::PARAM_STR:
if (!is_string($value))
throw new InvalidArgumentException("String parameter expected, got: " . gettype($value));
break;
case PDO::PARAM_BOOL:
if (!is_bool($value))
throw new InvalidArgumentException("Boolean parameter expected, got: " . gettype($value));
break;
default:
throw new InvalidArgumentException("Unsupported parameter type: {$type}");
break;
}
}
}
<?php
declare(strict_types=1);
namespace Database;
use Countable;
use IteratorAggregate;
use PDOStatement;
use Traversable;
final class ResultSet implements IteratorAggregate, Countable
{
public static function create(PDOStatement $pdoStatement): self
{
return new static($pdoStatement);
}
/** @var PDOStatement */
private $pdoStatement;
public function __construct(PDOStatement $pdoStatement)
{
$this->pdoStatement = $pdoStatement;
}
public function getIterator(): Traversable
{
return $this->pdoStatement;
}
public function count(): int
{
return $this->pdoStatement->rowCount(); // [TODO] check number of affected rows (not valid for SELECT in all drivers)
}
public function next(): ?array
{
return $this->pdoStatement->fetch() ?: null;
}
public function all(): array
{
return iterator_to_array($this);
}
public function map(callable $callback): array
{
return array_map($callback, $this->all());
}
public function __destruct()
{
$this->pdoStatement = null;
}
}
<?php
declare(strict_types=1);
namespace Database;
use InvalidArgumentException;
use PDO;
use PDOStatement;
use RuntimeException;
final class Statement
{
/** @var PDO */
private $pdo;
/** @var PDOStatement */
private $pdoStatement;
/** @var array */
private $boundParameters = [];
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function lastInsertId(): string
{
return (string) $this->pdo->lastInsertId();
}
public function prepare(string $query): self
{
if (empty(trim($query)))
throw new InvalidArgumentException("Query is required");
if (! $this->pdoStatement = $this->pdo->prepare($query))
throw new InvalidArgumentException($this->pdo->errorInfo()[2]); // [TODO] refine error message
return $this;
}
public function bindParameter(QueryParameter $parameter): self
{
if (in_array($parameter->name, $this->boundParameters, true))
throw new InvalidArgumentException("Duplicate parameter: {$parameter->name}");
if (!$this->pdoStatement->bindValue($parameter->name, $parameter->value, $parameter->type))
throw new RuntimeException("Failed to bind parameter");
$this->boundParameters[] = $parameter->name;
return $this;
}
public function bindParameters(array $parameters): self
{
foreach ($parameters as $parameter) {
if (is_object($parameter) && ! $parameter instanceof QueryParameter)
throw new InvalidArgumentException("Unsupported parameter type: " . get_class($parameter));
$this->bindParameter($parameter);
}
return $this;
}
public function execute(bool $debugDumpParams = false): ResultSet
{
if ($debugDumpParams)
$this->pdoStatement->debugDumpParams();
if (! $this->pdoStatement->execute())
throw new RuntimeException($this->pdo->errorInfo()[2]);
$this->boundParameters = [];
return ResultSet::create($this->pdoStatement);
}
public function __destruct()
{
$this->pdoStatement = null;
$this->pdo = null;
$this->boundParameters = [];
}
}
<?php
declare(strict_types=1);
namespace Database;
use PDO;
use RuntimeException;
use Throwable;
final class TransactionManager
{
public static function create(
PDO $pdo,
Statement $statement
): self {
return new self($pdo, $statement);
}
/** @var PDO */
private $pdo;
/** @var Statement */
private $statement;
private function __construct(
PDO $pdo,
Statement $statement
) {
$this->pdo = $pdo;
$this->statement = $statement;
}
public function transaction(callable $callback)
{
if ($this->pdo->inTransaction())
throw new RuntimeException("Nested transactions are not supported");
try {
$this->pdo->beginTransaction();
$boundCallback = $callback->bindTo($this->pdo);
$result = $boundCallback($this->statement);
$this->pdo->commit();
return $result;
} catch (Throwable $error) {
$this->pdo->rollBack();
throw new RuntimeException($error->getMessage(), $error->getCode(), $error);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment