Last active
February 12, 2025 12:25
-
-
Save nandordudas/22f11a6ed4649de9460d1bf1be2ebab3 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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"); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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 = []; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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