Skip to content

Instantly share code, notes, and snippets.

@nandordudas
Last active February 16, 2025 21:29
Show Gist options
  • Save nandordudas/bb420624ef73026a773de21d9064b8da to your computer and use it in GitHub Desktop.
Save nandordudas/bb420624ef73026a773de21d9064b8da to your computer and use it in GitHub Desktop.
<?php
declare(strict_types=1); // [INFO] PHP 7.2
namespace App;
// APP_ENV=development php -d xdebug.mode=off feature.php
// APP_ENV=development XDEBUG_CONFIG="log_level=0" php feature.php
// composer install --dev phpstan/phpstan symfony/var-dumper
if (getenv('APP_ENV') === 'development') {
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
}
if (interface_exists('Stringable') === false) {
interface Stringable
{
public function __toString(): string;
}
}
interface Closable
{
public function close(): void;
}
/** @extends \IteratorAggregate<int, mixed> */
interface QueryResultContract extends \IteratorAggregate, \Countable, Closable
{
/** @return ?array<string, mixed> */
public function next(): ?array;
/** @return ?array<string, mixed> */
public function first(): ?array;
}
abstract class QueryResultBase implements QueryResultContract
{
/** @var ?\PDOStatement */
protected $statement;
protected function __construct(\PDOStatement $statement)
{
$this->statement = $statement;
}
public function close(): void
{
$this->statement->closeCursor();
$this->statement = null;
}
/** @return ?array<string, mixed> */
public function first(): ?array
{
$result = $this->statement->fetch();
$this->close();
return $result;
}
/** @return ?array<string, mixed> */
public function next(): ?array
{
if ($this->statement === null)
return null;
return $this->statement->fetch() ?: null;
}
public function getIterator(): \Traversable
{
while ($row = $this->statement->fetch(\PDO::FETCH_ASSOC)) // [INFO] PDO options have been set before
yield $row;
$this->statement->closeCursor();
}
/**
* @warning May not work with SELECT in all database drivers
*/
public function count(): int
{
if ($this->statement === null)
return 0;
return $this->statement->rowCount();
}
public function __destruct()
{
$this->close();
}
}
final class QueryResult extends QueryResultBase
{
public static function create(\PDOStatement $statement): static
{
return new static($statement);
}
/** @return array<int, mixed> */
public function toArray(): array
{
return iterator_to_array($this);
}
/** @return array<int, mixed> */
public function column(string $column): array
{
return array_column($this->toArray(), $column);
}
}
final class Credentials
{
/** @param array<string, string> $config */
public static function fromArray(array $config): self
{
return new self($config);
}
/** @var string */
public $username;
/** @var string */
public $password;
/** @param array<string, string> $config */
public function __construct(array $config)
{
$this->validate($config);
$this->username = $config['username'];
$this->password = $config['password'];
}
/** @param array<string, string> $value */
private function validate(array $value): void
{
if (isset($value['username']) === false || isset($value['password']) === false)
throw new \InvalidArgumentException('Username and password are required');
if ((bool) preg_match('/^[a-zA-Z0-9_]{3,20}$/', $value['username']) === false)
throw new \InvalidArgumentException('Invalid username format');
if (empty(trim($value['password'])))
throw new \InvalidArgumentException('Password cannot be empty');
}
}
final class DatabaseConfig implements \Stringable
{
/** @param array<string, int|string> $config */
public static function fromArray(array $config): self
{
return new self(
$config['driver'],
$config['host'],
(int) $config['port'],
$config['database']
);
}
/** @var string */
private $driver;
/** @var string */
private $host;
/** @var int */
private $port;
/** @var string */
private $database;
public function __construct(string $driver, string $host, int $port, string $database)
{
$this->validateDriver($driver);
$this->validateHost($host);
$this->validatePort($port);
$this->validateName($database);
$this->driver = $driver;
$this->host = $host;
$this->port = $port;
$this->database = $database;
}
public function __toString(): string
{
return sprintf(
'%s:host=%s;port=%d;dbname=%s;charset=utf8mb4',
$this->driver,
$this->host,
$this->port,
$this->database
);
}
private function validateDriver(string $value): void
{
if (in_array($value, $allowed = \PDO::getAvailableDrivers(), true) === false)
throw new \InvalidArgumentException('Invalid driver. Allowed: ' . implode(', ', $allowed));
if ($value === 'sqlite')
throw new \InvalidArgumentException('SQLite driver is not supported yet'); // [FIXME]
}
private function validateHost(string $value): void
{
if (
(bool) filter_var($value, FILTER_VALIDATE_IP) === false
&& (bool) filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) === false
)
throw new \InvalidArgumentException('Invalid host format');
}
private function validatePort(int $value): void
{
if ((bool) filter_var($value, FILTER_VALIDATE_INT, ['min_range' => 1, 'max_range' => 65535]) === false)
throw new \InvalidArgumentException('Invalid port');
}
private function validateName(string $value): void
{
if ((bool) preg_match('/^[a-zA-Z0-9_\-]+$/', $value) === false)
throw new \InvalidArgumentException('Invalid database name');
}
}
class Database implements Closable
{
public static function create(\PDO $pdo): self
{
return new self($pdo);
}
private static function dataType(mixed $value): int
{
if (is_null($value))
return \PDO::PARAM_NULL;
if (is_int($value))
return \PDO::PARAM_INT;
if (is_resource($value))
return \PDO::PARAM_LOB;
if (is_bool($value))
return \PDO::PARAM_BOOL;
return \PDO::PARAM_STR;
}
/** @var ?\PDO */
private $pdo;
/** @var ?\PDOStatement */
private $statement;
/** @var array<string, mixed> */
private $boundParameters = [];
private function __construct(\PDO $pdo)
{
$this->pdo = $pdo;
}
public function lastInsertId(): string
{
return (string) $this->pdo->lastInsertId();
}
public function useTransaction(callable $callback): mixed
{
if ($this->pdo->inTransaction())
throw new \RuntimeException('Nested transactions not supported');
$this->pdo->beginTransaction();
try {
$result = $callback($this);
$this->pdo->commit();
return $result;
} catch (\Throwable $error) {
$this->pdo->rollBack();
throw $error;
}
}
/** @param array<string, mixed> $parameters */
public function executeQuery(string $query, array $parameters = []): QueryResult
{
return $this->prepare($query)->execute($parameters);
}
public function close(): void
{
$this->statement = null;
$this->boundParameters = [];
$this->pdo = null;
}
private function prepare(string $query): self
{
if (empty(trim($query)))
throw new \InvalidArgumentException('Query is required');
$this->statement = $this->pdo->prepare($query);
if ($this->statement === false)
throw new \InvalidArgumentException($this->pdo->errorInfo()[2]); // [TODO] refine
return $this;
}
/** @param array<string, mixed> $parameters */
private function execute(array $parameters = []): QueryResult
{
if ($this->statement === null)
throw new \LogicException('Prepare statement first');
if (count($parameters) > 0)
$this->bindParameters($parameters);
$this->boundParameters = [];
if ($this->statement->execute() === false)
throw new \InvalidArgumentException($this->pdo->errorInfo()[2]); // [TODO] refine
return QueryResult::create($this->statement);
}
/** @param array<string, mixed> $parameters */
private function bindParameters(array $parameters): void
{
if ($this->statement === null)
throw new \LogicException('Prepare statement first');
foreach ($parameters as $key => $value)
$this->bindParameter($key, $value);
}
private function bindParameter(string $key, mixed $value): void
{
if ((bool) preg_match('/^:[a-zA-Z_][a-zA-Z0-9_]*$/', $key) === false)
throw new \InvalidArgumentException("Invalid parameter name: $key");
if (isset($this->boundParameters[$key]))
throw new \RuntimeException("Parameter {$key} has already been bound");
$this->boundParameters[$key] = $value;
if ($this->statement->bindValue(':' . ltrim($key, ':'), $value, self::dataType($value)) === false)
throw new \RuntimeException("Failed to bind parameter {$key}");
}
}
{
"name": "PHP & MariaDB",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers/features/node": "latest"
},
"forwardPorts": [
8080,
3306
],
"remoteEnv": {
"XDEBUG_CONFIG": "log_level=0",
"APP_ENV": "development"
}
}
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ../..:/workspaces:cached
command: sleep infinity
network_mode: service:db
db:
image: mariadb:10.4
restart: unless-stopped
volumes:
- mariadb-data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: mariadb
MYSQL_DATABASE: mariadb
MYSQL_USER: mariadb
MYSQL_PASSWORD: mariadb
volumes:
mariadb-data:
FROM mcr.microsoft.com/devcontainers/php:1-8.2-bookworm
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get install -y mariadb-client \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-install mysqli pdo pdo_mysql
<?php
declare(strict_types=1); // [INFO] PHP 7.2
namespace App;
function pdoFactory(): \PDO
{
$credentials = Credentials::fromArray([
'username' => 'mariadb',
'password' => 'mariadb',
]);
$dsn = (string) DatabaseConfig::fromArray([
'driver' => 'mysql',
'host' => 'db',
'port' => 3306,
'database' => 'mariadb',
'charset' => 'utf8mb4',
]);
$pdoOptions = [
\PDO::ATTR_TIMEOUT => 3,
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, // [INFO]
\PDO::ATTR_PERSISTENT => false,
\PDO::ATTR_STATEMENT_CLASS => [\PDOStatement::class],
\PDO::ATTR_STRINGIFY_FETCHES => false,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_EMULATE_PREPARES => false,
];
return new \PDO($dsn, $credentials->username, $credentials->password, $pdoOptions);
}
parameters:
level: 6 # increase to max
paths:
- .
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment