Last active
February 16, 2025 21:29
-
-
Save nandordudas/bb420624ef73026a773de21d9064b8da 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); // [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}"); | |
} | |
} |
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
Show hidden characters
{ | |
"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" | |
} | |
} |
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
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: |
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
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 |
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); // [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); | |
} |
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
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