Last active
October 2, 2025 14:12
-
-
Save nandordudas/841f16708f2b387e9e9e2c300b252a74 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); | |
| use Tracy\Debugger; | |
| const ROOT = __DIR__ . '/../'; | |
| require ROOT . '/vendor/autoload.php'; | |
| Debugger::enable(Debugger::detectDebugMode(), ROOT . '/logs'); | |
| final class Config | |
| { | |
| const DATE_FORMAT = 'Y-m-d H:i:s'; | |
| const RESOURCE_METHODS = [ | |
| 'except' => ['create', 'edit'], | |
| ]; | |
| } | |
| final readonly class User implements \JsonSerializable | |
| { | |
| /** | |
| * | |
| * @param array{id: int, name: string, email: string, created_at: string, updated_at: string} $data | |
| * @return self | |
| */ | |
| public static function fromArray(array $data): self | |
| { | |
| try { | |
| return new self( | |
| id: (int) $data['id'], | |
| name: $data['name'], | |
| email: $data['email'], | |
| createdAt: new \DateTimeImmutable($data['created_at']), | |
| updatedAt: new \DateTimeImmutable($data['updated_at']), | |
| ); | |
| } catch (\DateMalformedStringException $e) { | |
| throw new \InvalidArgumentException("Invalid date format: {$e->getMessage()}", previous: $e); | |
| } catch (\Exception $e) { | |
| throw new \InvalidArgumentException("Failed to create User: {$e->getMessage()}", previous: $e); | |
| } | |
| } | |
| public function __construct( | |
| public int $id, | |
| public string $name, | |
| public string $email, | |
| public \DateTimeImmutable $createdAt, | |
| public \DateTimeImmutable $updatedAt, | |
| ) { | |
| $this->validate(); | |
| } | |
| public function toArray(): array | |
| { | |
| return [ | |
| 'id' => $this->id, | |
| 'name' => $this->name, | |
| 'email' => $this->email, | |
| 'created_at' => $this->createdAt->format(Config::DATE_FORMAT), | |
| 'updated_at' => $this->updatedAt->format(Config::DATE_FORMAT), | |
| ]; | |
| } | |
| public function jsonSerialize(): array | |
| { | |
| return $this->toArray(); | |
| } | |
| private function validate(): void | |
| { | |
| if ($this->id <= 0) { | |
| throw new \InvalidArgumentException('ID must be positive'); | |
| } | |
| if (empty($this->name)) { | |
| throw new \InvalidArgumentException('Name cannot be empty'); | |
| } | |
| if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) { | |
| throw new \InvalidArgumentException('Invalid email format'); | |
| } | |
| } | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| interface DTOInterface | |
| { | |
| /** | |
| * | |
| * @return array<string, mixed> | |
| */ | |
| public function toArray(): array; | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| abstract class DTO implements DTOInterface | |
| { | |
| abstract protected function validate(): void; | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| final class CreateUserDTO extends DTO | |
| { | |
| public function __construct( | |
| public readonly string $name, | |
| public readonly string $email, | |
| ) { | |
| $this->validate(); | |
| } | |
| protected function validate(): void | |
| { | |
| if (empty($this->name)) { | |
| throw new \InvalidArgumentException('Name is required'); | |
| } | |
| if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) { | |
| throw new \InvalidArgumentException('Invalid email format'); | |
| } | |
| } | |
| /** | |
| * | |
| * {@inheritDoc} | |
| */ | |
| public function toArray(): array | |
| { | |
| return [ | |
| 'name' => $this->name, | |
| 'email' => $this->email, | |
| ]; | |
| } | |
| } | |
| final class UpdateUserDTO extends DTO | |
| { | |
| public function __construct( | |
| public readonly ?string $name = null, | |
| public readonly ?string $email = null, | |
| ) { | |
| $this->validate(); | |
| } | |
| protected function validate(): void | |
| { | |
| if ($this->name !== null && $this->name === '') { | |
| throw new \InvalidArgumentException('Name cannot be empty'); | |
| } | |
| if ($this->email !== null && !filter_var($this->email, FILTER_VALIDATE_EMAIL)) { | |
| throw new \InvalidArgumentException('Invalid email format'); | |
| } | |
| if ($this->name === null && $this->email === null) { | |
| throw new \InvalidArgumentException('At least one field must be provided'); | |
| } | |
| } | |
| public function toArray(): array | |
| { | |
| $data = []; | |
| if ($this->name !== null) { | |
| $data['name'] = $this->name; | |
| } | |
| if ($this->email !== null) { | |
| $data['email'] = $this->email; | |
| } | |
| return $data; | |
| } | |
| } | |
| final readonly class Pagination | |
| { | |
| public function __construct( | |
| public int $page = 1, | |
| public int $perPage = 15, | |
| public int $maxPerPage = 100, | |
| ) { | |
| $this->validate(); | |
| } | |
| public static function fromRequest(\flight\net\Request $request): self | |
| { | |
| return new self( | |
| page: max(1, (int) ($request->query->page ?? 1)), | |
| perPage: max(1, min(100, (int) ($request->query->per_page ?? 15))), | |
| ); | |
| } | |
| public function offset(): int | |
| { | |
| return ($this->page - 1) * $this->perPage; | |
| } | |
| public function limit(): int | |
| { | |
| return $this->perPage; | |
| } | |
| private function validate(): void | |
| { | |
| if ($this->page < 1) { | |
| throw new \InvalidArgumentException('Page must be greater than 0'); | |
| } | |
| if ($this->perPage < 1 || $this->perPage > $this->maxPerPage) { | |
| throw new \InvalidArgumentException("Per page must be between 1 and {$this->maxPerPage}"); | |
| } | |
| } | |
| } | |
| interface ResultInterface extends \IteratorAggregate, \Countable | |
| { | |
| public function fetchOne(): ?array; | |
| public function fetchAll(): array; | |
| public function first(): ?array; | |
| public function close(): void; | |
| } | |
| class Result implements ResultInterface | |
| { | |
| private bool $isClosed = false; | |
| public function __construct( | |
| private \PDOStatement $stmt, | |
| ) {} | |
| public function fetchOne(): ?array | |
| { | |
| $this->ensureNotClosed(); | |
| $result = $this->stmt->fetch(); | |
| return $result === false ? null : $result; | |
| } | |
| public function first(): ?array | |
| { | |
| $row = $this->fetchOne(); | |
| $this->close(); | |
| return $row; | |
| } | |
| public function close(): void | |
| { | |
| if (!$this->isClosed) { | |
| $this->stmt->closeCursor(); | |
| $this->isClosed = true; | |
| } | |
| } | |
| public function fetchAll(): array | |
| { | |
| $this->ensureNotClosed(); | |
| $data = $this->stmt->fetchAll(); | |
| $this->close(); | |
| return $data; | |
| } | |
| public function getIterator(): \Traversable | |
| { | |
| $this->ensureNotClosed(); | |
| try { | |
| while ($row = $this->stmt->fetch()) { | |
| yield $row; | |
| } | |
| } finally { | |
| $this->close(); | |
| } | |
| } | |
| public function count(): int | |
| { | |
| $this->ensureNotClosed(); | |
| return $this->stmt->rowCount(); | |
| } | |
| public function __destruct() | |
| { | |
| $this->close(); | |
| } | |
| private function ensureNotClosed(): void | |
| { | |
| if ($this->isClosed) { | |
| throw new \LogicException('Result set has been closed'); | |
| } | |
| } | |
| } | |
| final readonly class PaginatedResult implements \JsonSerializable | |
| { | |
| public function __construct( | |
| public array $items, | |
| public int $total, | |
| public Pagination $pagination, | |
| ) {} | |
| public function hasMore(): bool | |
| { | |
| return $this->pagination->offset() + count($this->items) < $this->total; | |
| } | |
| public function totalPages(): int | |
| { | |
| return (int) ceil($this->total / $this->pagination->perPage); | |
| } | |
| public function jsonSerialize(): array | |
| { | |
| return [ | |
| 'meta' => [ | |
| 'total' => $this->total, | |
| 'per_page' => $this->pagination->perPage, | |
| 'last_page' => $this->totalPages(), | |
| 'current_page' => $this->pagination->page, | |
| 'from' => $this->total === 0 ? 0 : $this->pagination->offset() + 1, | |
| 'to' => min($this->pagination->offset() + count($this->items), $this->total), | |
| ], | |
| 'data' => $this->items, | |
| ]; | |
| } | |
| } | |
| interface ConnectionInterface | |
| { | |
| public function connect(): void; | |
| public function disconnect(): void; | |
| } | |
| interface DatabaseInterface extends ConnectionInterface | |
| { | |
| public function getPDO(): \PDO; | |
| public function execute(string $sql, array $params = []): int; | |
| public function lastInsertId(?string $name = null): string|false; | |
| public function transaction(callable $callback): mixed; | |
| public function runQuery(string $sql, array $params = []): ResultInterface; | |
| public function fetchOne(string $sql, array $params = []): ?array; | |
| public function fetchAll(string $sql, array $params = []): array; | |
| } | |
| final class Database implements DatabaseInterface | |
| { | |
| private ?\PDO $pdo = null; | |
| public bool $isConnected { | |
| get => $this->pdo !== null; | |
| } | |
| public function connect(): void | |
| { | |
| if ($this->isConnected) { | |
| return; | |
| } | |
| $this->pdo = new \PDO( | |
| dsn: sprintf( | |
| '%s:host=%s;port=%d;dbname=%s;charset=%s', | |
| 'mysql', | |
| getenv('DB_HOST') ?: throw new \RuntimeException('DB_HOST required'), | |
| (int) (getenv('DB_PORT') ?: 3306), | |
| getenv('DB_DATABASE') ?: throw new \RuntimeException('DB_DATABASE required'), | |
| getenv('DB_CHARSET') ?: 'utf8mb4' | |
| ), | |
| username: getenv('DB_USERNAME') ?: throw new \RuntimeException('DB_USERNAME required'), | |
| password: getenv('DB_PASSWORD') ?: throw new \RuntimeException('DB_PASSWORD required'), | |
| options: [ | |
| \PDO::ATTR_TIMEOUT => 10, | |
| \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, | |
| \PDO::ATTR_PERSISTENT => false, | |
| \PDO::ATTR_STRINGIFY_FETCHES => false, | |
| \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, | |
| \PDO::ATTR_EMULATE_PREPARES => false, | |
| ], | |
| ); | |
| } | |
| public function disconnect(): void | |
| { | |
| if (!$this->isConnected) { | |
| return; | |
| } | |
| $this->pdo = null; | |
| } | |
| public function getPDO(): \PDO | |
| { | |
| $this->lazyConnect(); | |
| return $this->pdo; | |
| } | |
| public function execute(string $sql, array $params = []): int | |
| { | |
| $stmt = $this->getPdo()->prepare($sql); | |
| $stmt->execute($params); | |
| return $stmt->rowCount(); | |
| } | |
| public function lastInsertId(?string $name = null): string|false | |
| { | |
| return $this->getPdo()->lastInsertId($name); | |
| } | |
| public function transaction(callable $callback): mixed | |
| { | |
| $pdo = $this->getPdo(); | |
| if ($pdo->inTransaction()) { | |
| throw new \LogicException('Transactions cannot be nested.'); | |
| } | |
| try { | |
| $pdo->beginTransaction(); | |
| $result = $callback($this); | |
| $pdo->commit(); | |
| return $result; | |
| } catch (\PDOException $e) { | |
| $pdo->rollBack(); | |
| throw new \PDOException("Transaction failed: {$e->getMessage()}", (int) $e->getCode(), $e); | |
| } | |
| } | |
| public function runQuery(string $sql, array $params = []): ResultInterface | |
| { | |
| try { | |
| $stmt = $this->getPDO()->prepare($sql); | |
| $stmt->execute($params); | |
| return new Result($stmt); | |
| } catch (\Throwable $e) { | |
| throw new \RuntimeException("Database query failed: {$e->getMessage()}", (int) $e->getCode(), $e); | |
| } | |
| } | |
| public function fetchOne(string $sql, array $params = []): ?array | |
| { | |
| return $this->runQuery($sql, $params)->first(); | |
| } | |
| public function fetchAll(string $sql, array $params = []): array | |
| { | |
| return $this->runQuery($sql, $params)->fetchAll(); | |
| } | |
| private function lazyConnect(): void | |
| { | |
| if ($this->isConnected) { | |
| return; | |
| } | |
| $this->connect(); | |
| } | |
| } | |
| interface RepositoryInterface | |
| { | |
| public function findAll(Pagination $pagination): PaginatedResult; | |
| } | |
| abstract class Repository implements RepositoryInterface | |
| { | |
| public function __construct( | |
| protected DatabaseInterface $database, | |
| ) {} | |
| } | |
| final class UserRepository extends Repository | |
| { | |
| /** | |
| * | |
| * @return \User[] | |
| * @throws \PDOException | |
| */ | |
| public function findAll(Pagination $pagination): PaginatedResult | |
| { | |
| $totalResult = $this->database->runQuery('SELECT COUNT(*) as total FROM users'); | |
| $total = (int) $totalResult->first()['total']; | |
| $result = $this->database->runQuery( | |
| 'SELECT * FROM users ORDER BY created_at DESC LIMIT ? OFFSET ?', | |
| [$pagination->limit(), $pagination->offset()] | |
| ); | |
| $users = array_map([User::class, 'fromArray'], $result->fetchAll()); | |
| return new PaginatedResult($users, $total, $pagination); | |
| } | |
| public function findById(int $id): ?User | |
| { | |
| if ($id <= 0) { | |
| return null; | |
| } | |
| $result = $this->database->runQuery('SELECT * FROM users WHERE id = ?', [$id]); | |
| return ($data = $result->fetchOne()) ? User::fromArray($data) : null; | |
| } | |
| public function create(CreateUserDTO $data): void | |
| { | |
| $this->database->execute( | |
| 'INSERT INTO users (name, email) VALUES (?, ?)', | |
| [$data->name, $data->email], | |
| ); | |
| } | |
| public function update(int $id, UpdateUserDTO $data): void | |
| { | |
| $updates = []; | |
| $params = []; | |
| if ($data->name !== null) { | |
| $updates[] = 'name = ?'; | |
| $params[] = $data->name; | |
| } | |
| if ($data->email !== null) { | |
| $updates[] = 'email = ?'; | |
| $params[] = $data->email; | |
| } | |
| if (empty($updates)) { | |
| return; | |
| } | |
| $params[] = $id; | |
| $this->database->execute( | |
| 'UPDATE users SET ' . implode(', ', $updates) . ' WHERE id = ?', | |
| $params, | |
| ); | |
| } | |
| public function delete(int $id): int | |
| { | |
| return $this->database->execute('DELETE FROM users WHERE id = ?', [$id]); | |
| } | |
| } | |
| interface RestControllerInterface | |
| { | |
| public function index(): void; | |
| public function store(): void; | |
| public function show(int $id): void; | |
| public function update(int $id): void; | |
| public function destroy(int $id): void; | |
| } | |
| abstract class Controller {} | |
| abstract class RestController extends Controller implements RestControllerInterface {} | |
| class UsersController extends RestController | |
| { | |
| public function __construct( | |
| private readonly UserRepository $userRepository, | |
| ) {} | |
| public function index(): void | |
| { | |
| $pagination = Pagination::fromRequest(Flight::request()); | |
| Flight::json($this->userRepository->findAll($pagination)); | |
| } | |
| public function show(int $id): void | |
| { | |
| $user = $this->userRepository->findById($id); | |
| if ($user === null) { | |
| Flight::jsonHalt(['error' => 'Resource not found'], 404); | |
| } | |
| Flight::json($user); | |
| } | |
| public function store(): void | |
| { | |
| $data = Flight::request()->data; | |
| try { | |
| if (!isset($data->name, $data->email)) { | |
| Flight::jsonHalt(['error' => 'Missing required fields'], 400); | |
| } | |
| $this->userRepository->create( | |
| data: new CreateUserDTO( | |
| name: $data->name, | |
| email: $data->email, | |
| ), | |
| ); | |
| Flight::json([], 201); | |
| } catch (\InvalidArgumentException $e) { | |
| Debugger::log($e, Debugger::ERROR); | |
| Flight::jsonHalt(['error' => 'Invalid data provided'], 400); | |
| } catch (\PDOException $e) { | |
| Debugger::log($e, Debugger::ERROR); | |
| Flight::jsonHalt(['error' => 'Failed to create user'], 500); | |
| } | |
| } | |
| public function update(int $id): void | |
| { | |
| $data = Flight::request()->data; | |
| try { | |
| $this->userRepository->update( | |
| id: $id, | |
| data: new UpdateUserDTO( | |
| name: $data->name ?? null, | |
| email: $data->email ?? null, | |
| ), | |
| ); | |
| Flight::response()->status(204)->send(); | |
| } catch (\InvalidArgumentException $e) { | |
| Debugger::log($e, Debugger::ERROR); | |
| Flight::jsonHalt(['error' => 'Invalid data provided'], 400); | |
| } | |
| } | |
| public function destroy(int $id): void | |
| { | |
| $this->userRepository->delete($id); | |
| Flight::response()->status(204)->send(); | |
| } | |
| } | |
| interface MiddlewareInterface | |
| { | |
| public function before(array $params): bool; | |
| public function after(array $params): void; | |
| } | |
| abstract class Middleware implements MiddlewareInterface | |
| { | |
| public function __construct(protected \flight\Engine $app) {} | |
| } | |
| final class RateLimitMiddleware extends Middleware | |
| { | |
| public function before(array $params): bool | |
| { | |
| return true; | |
| } | |
| public function after(array $params): void {} | |
| } | |
| $container = new \flight\Container(); | |
| $container->singleton( | |
| DatabaseInterface::class, | |
| static fn(): DatabaseInterface => new Database(), | |
| ); | |
| \Flight::registerContainerHandler($container->get(...)); | |
| \Flight::group('/v1', static function (): void { | |
| $routes = [ | |
| UsersController::class => '/users', | |
| ]; | |
| array_walk($routes, [\Flight::class, 'resource'], Config::RESOURCE_METHODS); | |
| }, [RateLimitMiddleware::class]); | |
| \Flight::start(); |
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); | |
| use Tracy\Debugger; | |
| const ROOT = __DIR__ . '/../'; | |
| require ROOT . '/vendor/autoload.php'; | |
| Debugger::enable(Debugger::detectDebugMode(), ROOT . '/logs'); | |
| final class ConnectionConfig | |
| { | |
| private const DSN_FORMAT = '%s:host=%s;port=%d;dbname=%s;charset=%s'; | |
| public string $dsn { | |
| get => sprintf(self::DSN_FORMAT, $this->driver, $this->host, $this->port, $this->database, $this->charset); | |
| } | |
| public function __construct( | |
| public readonly string $driver, | |
| public readonly string $host, | |
| public readonly int $port, | |
| public readonly string $database, | |
| public readonly string $username, | |
| #[\SensitiveParameter] | |
| public readonly string $password, | |
| public readonly string $charset = 'utf8mb4', | |
| public readonly ?array $options = null, | |
| ) { | |
| $this->validate(); | |
| } | |
| private function validate(): void | |
| { | |
| // [TODO] only mysql is supported for now | |
| } | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| interface ConnectionInterface | |
| { | |
| public function connect(): void; | |
| public function disconnect(): void; | |
| public function getPDO(): \PDO; | |
| /** | |
| * | |
| * @param string $sql The SQL query to execute | |
| * @param array<string|int, mixed> $params Parameters to bind to the query | |
| * @return ResultInterface<TRow> The query result set | |
| * @throws \RuntimeException If the query fails | |
| */ | |
| public function query(string $sql, array $params = []): ResultInterface; | |
| /** | |
| * | |
| * @param string $sql The SQL query to execute | |
| * @param array<string|int, mixed> $params Parameters to bind to the query | |
| * @return TRow|null An associative array representing the first row, or null if no results | |
| */ | |
| public function fetchOne(string $sql, array $params = []): ?array; | |
| /** | |
| * | |
| * @param string $sql The SQL query to execute | |
| * @param array<string|int, mixed> $params Parameters to bind to the query | |
| * @return array<int, TRow> An array of associative arrays representing all rows | |
| */ | |
| public function fetchAll(string $sql, array $params = []): array; | |
| /** | |
| * Execute a statement and return the number of affected rows. | |
| * | |
| * @param string $sql The SQL statement to execute | |
| * @param array<string|int, mixed> $params Parameters to bind to the statement | |
| * @return int The number of affected rows | |
| * @throws \RuntimeException If the statement fails | |
| */ | |
| public function execute(string $sql, array $params = []): int; | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| class Connection implements ConnectionInterface | |
| { | |
| private ?\PDO $pdo = null; | |
| public bool $isConnected { | |
| get => $this->pdo !== null; | |
| } | |
| public function __construct( | |
| private readonly ConnectionConfig $config, | |
| ) {} | |
| public function connect(): void | |
| { | |
| if ($this->isConnected) { | |
| return; | |
| } | |
| $options = $this->config->options ?? []; | |
| $options[\PDO::ATTR_TIMEOUT] ??= 10; | |
| $options[\PDO::ATTR_ERRMODE] ??= \PDO::ERRMODE_EXCEPTION; | |
| $options[\PDO::ATTR_PERSISTENT] ??= false; | |
| $options[\PDO::ATTR_STRINGIFY_FETCHES] ??= false; | |
| $options[\PDO::ATTR_DEFAULT_FETCH_MODE] ??= \PDO::FETCH_ASSOC; | |
| $options[\PDO::ATTR_EMULATE_PREPARES] ??= false; | |
| try { | |
| $this->pdo = new \PDO($this->config->dsn, $this->config->username, $this->config->password, $options); | |
| } catch (\PDOException $e) { | |
| throw new \RuntimeException("Failed to connect to database: {$e->getMessage()}", (int) $e->getCode(), $e); | |
| } | |
| } | |
| public function disconnect(): void | |
| { | |
| if (!$this->isConnected) { | |
| return; | |
| } | |
| $this->pdo = null; | |
| } | |
| public function getPDO(): \PDO | |
| { | |
| $this->ensureConnected(); | |
| return $this->pdo; | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function query(string $sql, array $params = []): ResultInterface | |
| { | |
| $pdo = $this->getPDO(); | |
| try { | |
| $stmt = $pdo->prepare($sql); | |
| if (!$stmt->execute($params)) { | |
| [$sqlStateErrorCode,, $errorMessage] = $stmt->errorInfo(); | |
| throw new \RuntimeException("Query failed: {$errorMessage} (SQLSTATE: {$sqlStateErrorCode})"); | |
| } | |
| return new Result($stmt); | |
| } catch (\Throwable $e) { | |
| throw new \RuntimeException("Database query failed: {$e->getMessage()}", (int) $e->getCode(), $e); | |
| } | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function fetchOne(string $sql, array $params = []): ?array | |
| { | |
| return $this->query($sql, $params)->first(); | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function fetchAll(string $sql, array $params = []): array | |
| { | |
| return $this->query($sql, $params)->fetchAll(); | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function execute(string $sql, array $params = []): int | |
| { | |
| $pdo = $this->getPdo(); | |
| try { | |
| $stmt = $pdo->prepare($sql); | |
| $stmt->execute($params); | |
| return $stmt->rowCount(); | |
| } catch (\PDOException $e) { | |
| throw new \RuntimeException("Database execute failed: {$e->getMessage()}", (int) $e->getCode(), $e); | |
| } | |
| } | |
| private function ensureConnected(): void | |
| { | |
| if (!$this->isConnected) { | |
| $this->connect(); | |
| } | |
| } | |
| } | |
| interface PaginatorInterface extends JsonSerializable | |
| { | |
| public function from(): int; | |
| public function to(): int; | |
| public function hasNextPage(): bool; | |
| public function nextPage(): ?int; | |
| public function hasPreviousPage(): bool; | |
| public function previousPage(): ?int; | |
| public function lastPage(): int; | |
| } | |
| class Paginator implements PaginatorInterface | |
| { | |
| // public static function make(array $items, int $total, int $page, int $perPage): self | |
| // { | |
| // return new self($items, $total, $page, $perPage); | |
| // } | |
| public function __construct( | |
| private readonly array $items, | |
| private readonly int $total, | |
| private readonly int $currentPage, | |
| private readonly int $perPage, | |
| ) {} | |
| public function from(): int | |
| { | |
| if ($this->total === 0) { | |
| return 0; | |
| } | |
| return (($this->currentPage - 1) * $this->perPage) + 1; | |
| } | |
| public function to(): int | |
| { | |
| if ($this->total === 0) { | |
| return 0; | |
| } | |
| return min($this->currentPage * $this->perPage, $this->total); | |
| } | |
| public function hasNextPage(): bool | |
| { | |
| return $this->currentPage < $this->lastPage(); | |
| } | |
| public function nextPage(): ?int | |
| { | |
| return $this->hasNextPage() ? $this->currentPage + 1 : null; | |
| } | |
| public function hasPreviousPage(): bool | |
| { | |
| return $this->currentPage > 1; | |
| } | |
| public function previousPage(): ?int | |
| { | |
| return $this->hasPreviousPage() ? $this->currentPage - 1 : null; | |
| } | |
| public function lastPage(): int | |
| { | |
| return (int) ceil($this->total / $this->perPage); | |
| } | |
| #[\Override] | |
| #[\ReturnTypeWillChange] | |
| public function jsonSerialize(): array | |
| { | |
| return [ | |
| 'data' => $this->items, | |
| 'meta' => [ | |
| 'total' => $this->total, | |
| 'per_page' => $this->perPage, | |
| 'current_page' => $this->currentPage, | |
| 'last_page' => $this->lastPage(), | |
| 'from' => $this->from(), | |
| 'to' => $this->to(), | |
| ], | |
| 'links' => [ | |
| 'first' => 1, | |
| 'last' => $this->lastPage(), | |
| 'prev' => $this->previousPage(), | |
| 'next' => $this->nextPage(), | |
| ], | |
| ]; | |
| } | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| interface ResultInterface extends \IteratorAggregate, \Countable | |
| { | |
| /** | |
| * | |
| * @return TRow|null The next row, or null if there are no more rows | |
| */ | |
| public function fetch(): ?array; | |
| public function close(): void; | |
| /** | |
| * | |
| * @return TRow|null The first row, or null if no results | |
| */ | |
| public function first(): ?array; | |
| /** | |
| * | |
| * @return array<int, TRow> An array of all rows | |
| */ | |
| public function fetchAll(): array; | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| class Result implements ResultInterface | |
| { | |
| private bool $isClosed = false; | |
| public function __construct( | |
| private \PDOStatement $stmt, | |
| ) {} | |
| public function fetch(): ?array | |
| { | |
| $this->ensureNotClosed(); | |
| $result = $this->stmt->fetch(); | |
| return $result === false ? null : $result; | |
| } | |
| public function close(): void | |
| { | |
| if (!$this->isClosed) { | |
| $this->stmt->closeCursor(); | |
| $this->isClosed = true; | |
| } | |
| } | |
| public function first(): ?array | |
| { | |
| $row = $this->fetch(); | |
| $this->close(); | |
| return $row; | |
| } | |
| public function fetchAll(): array | |
| { | |
| $this->ensureNotClosed(); | |
| $data = $this->stmt->fetchAll(); | |
| $this->close(); | |
| return $data; | |
| } | |
| /** | |
| * | |
| * @return \Traversable<int, TRow> | |
| */ | |
| #[\Override] | |
| public function getIterator(): \Traversable | |
| { | |
| $this->ensureNotClosed(); | |
| try { | |
| while ($row = $this->stmt->fetch()) { | |
| yield $row; | |
| } | |
| } finally { | |
| $this->close(); | |
| } | |
| } | |
| #[\Override] | |
| public function count(): int | |
| { | |
| $this->ensureNotClosed(); | |
| return $this->stmt->rowCount(); | |
| } | |
| public function __destruct() | |
| { | |
| $this->close(); | |
| } | |
| private function ensureNotClosed(): void | |
| { | |
| if ($this->isClosed) { | |
| throw new \LogicException('Result set has been closed'); | |
| } | |
| } | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| interface RepositoryInterface | |
| { | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| * @return \PaginatorInterface | |
| */ | |
| public function all(): PaginatorInterface; | |
| } | |
| /** | |
| * | |
| * @template TRow of array<string, mixed> | |
| */ | |
| abstract class Repository implements RepositoryInterface | |
| { | |
| public function __construct( | |
| protected readonly ConnectionInterface $connection, | |
| ) {} | |
| protected function execute(string $sql, array $params = []): int | |
| { | |
| return $this->connection->execute($sql, $params); | |
| } | |
| protected function paginate( | |
| string $sql, | |
| string $countSql, | |
| array $params = [], | |
| int $page = 1, | |
| int $perPage = 15, | |
| ): Paginator { | |
| // $cacheKey = 'count:' . md5($sql . serialize($params)); | |
| $total = (int) $this->connection->fetchOne($countSql, $params)['total'] ?? 0; | |
| if ($total === 0) { | |
| return new Paginator([], 0, $page, $perPage); | |
| } | |
| $items = $this->connection->fetchAll( | |
| sql: $sql . sprintf(' LIMIT %d OFFSET %d', $perPage, ($page - 1) * $perPage), | |
| params: $params, | |
| ); | |
| return new Paginator($items, $total, $page, $perPage); | |
| } | |
| protected function buildCountQuery(string $sql): string | |
| { | |
| if (preg_match('/\bFROM\b(.+?)(?:\bORDER BY\b|\bLIMIT\b|\bGROUP BY\b|$)/is', $sql, $matches)) { | |
| $fromClause = trim($matches[1]); | |
| return "SELECT COUNT(*) as total FROM {$fromClause}"; | |
| } | |
| return "SELECT COUNT(*) as total FROM ({$sql}) as count_table"; | |
| } | |
| } | |
| interface ServiceInterface {} | |
| abstract class Service implements ServiceInterface {} | |
| interface ControllerInterface {} | |
| interface RestControllerInterface extends ControllerInterface | |
| { | |
| public function index(): void; | |
| public function store(): void; | |
| public function show(int $id): void; | |
| public function update(int $id): void; | |
| public function destroy(int $id): void; | |
| } | |
| trait RestControllerTrait | |
| { | |
| public function index(): void | |
| { | |
| Flight::jsonHalt(['message' => 'Page Not Found'], 404); | |
| } | |
| public function show(int $id): void | |
| { | |
| Flight::jsonHalt(['message' => 'Page Not Found', 'id' => $id], 404); | |
| } | |
| public function store(): void | |
| { | |
| Flight::jsonHalt(['message' => 'Page Not Found'], 404); | |
| } | |
| public function update(int $id): void | |
| { | |
| Flight::jsonHalt(['message' => 'Page Not Found', 'id' => $id], 404); | |
| } | |
| public function destroy(int $id): void | |
| { | |
| Flight::jsonHalt(['message' => 'Page Not Found', 'id' => $id], 404); | |
| } | |
| } | |
| abstract class Controller implements ControllerInterface {} | |
| abstract class RestController extends Controller implements RestControllerInterface | |
| { | |
| use RestControllerTrait; | |
| } | |
| final readonly class UserDto implements \JsonSerializable | |
| { | |
| /** | |
| * | |
| * @param array{id: int, name: string, email: string, created_at: string, updated_at: string} $data | |
| * @return \UserDto | |
| */ | |
| public static function fromArray(array $data): self | |
| { | |
| return new self( | |
| id: (int) $data['id'], | |
| name: $data['name'], | |
| email: $data['email'], | |
| createdAt: new \DateTimeImmutable($data['created_at']), | |
| updatedAt: new \DateTimeImmutable($data['updated_at']), | |
| ); | |
| } | |
| public function __construct( | |
| public int $id, | |
| public string $name, | |
| public string $email, | |
| public \DateTimeImmutable $createdAt, | |
| public \DateTimeImmutable $updatedAt, | |
| ) {} | |
| public function toArray(): array | |
| { | |
| return [ | |
| 'id' => $this->id, | |
| 'name' => $this->name, | |
| 'email' => $this->email, | |
| 'created_at' => $this->createdAt->format('Y-m-d H:i:s'), | |
| 'updated_at' => $this->updatedAt->format('Y-m-d H:i:s'), | |
| ]; | |
| } | |
| #[\Override] | |
| #[\ReturnTypeWillChange] | |
| public function jsonSerialize(): array | |
| { | |
| return $this->toArray(); | |
| } | |
| } | |
| /** | |
| * | |
| * @template TRow of array{id: int, name: string, email: string, created_at: string, updated_at: string} | |
| */ | |
| class UserRepository extends Repository | |
| { | |
| public function all(int $page = 1, int $perPage = 15): PaginatorInterface | |
| { | |
| return $this->paginate( | |
| sql: 'SELECT * FROM users ORDER BY created_at DESC', | |
| countSql: 'SELECT COUNT(*) as total FROM users', | |
| page: $page, | |
| perPage: $perPage, | |
| ); | |
| } | |
| public function find(int $id): ?UserDto | |
| { | |
| // $key = static::class . ":find:{$id}"; | |
| $data = $this->connection->fetchOne( | |
| sql: 'SELECT * FROM users AS u WHERE u.id = :id', | |
| params: ['id' => $id], | |
| ); | |
| return $data ? UserDto::fromArray($data) : null; | |
| } | |
| /** | |
| * | |
| * @param array{name: string, email: string} $data | |
| * @return void | |
| * @throws \RuntimeException | |
| */ | |
| public function create(array $data): void | |
| { | |
| $this->connection->execute( | |
| sql: <<<SQL | |
| INSERT INTO users (`name`, email) | |
| VALUES (:name, :email) | |
| ON DUPLICATE KEY UPDATE | |
| id = LAST_INSERT_ID(id), | |
| `name` = VALUES(`name`), | |
| updated_at = NOW() | |
| SQL, | |
| params: $data, | |
| ); | |
| } | |
| } | |
| class UserService extends Service | |
| { | |
| public function __construct( | |
| private readonly UserRepository $userRepository, | |
| ) {} | |
| public function list(): PaginatorInterface | |
| { | |
| $query = \Flight::request()->query; | |
| $page = (int) $query['page'] ?: 1; | |
| $perPage = (int) $query['per_page'] ?: 15; | |
| return $this->userRepository->all( | |
| page: $page, | |
| perPage: $perPage, | |
| ); | |
| } | |
| public function getById(int $id): ?UserDto | |
| { | |
| return $this->userRepository->find($id); | |
| } | |
| /** | |
| * @param array{name: string, email: string} $data | |
| * @return void | |
| * @throws \RuntimeException | |
| */ | |
| public function register(array $data): void | |
| { | |
| $this->userRepository->create($data); | |
| } | |
| } | |
| class UserController extends RestController | |
| { | |
| public function __construct( | |
| private readonly UserService $userService, | |
| ) {} | |
| #[\Override] | |
| public function index(): void | |
| { | |
| Flight::json($this->userService->list()); | |
| } | |
| #[\Override] | |
| public function show(int $id): void | |
| { | |
| if ($id < 1) { | |
| Flight::jsonHalt(['error' => 'Invalid ID'], 400); | |
| } | |
| $user = $this->userService->getById($id); | |
| if ($user === null) { | |
| Flight::jsonHalt(['error' => 'Resource not found'], 404); | |
| } | |
| Flight::json($user); // [INFO] User type is still `null|\UserDto $user` | |
| } | |
| #[\Override] | |
| public function store(): void | |
| { | |
| // [INFO] Dedicated validator should be used here. | |
| $allowed = ['name', 'email']; | |
| $data = array_intersect_key( | |
| Flight::request()->data->getData(), | |
| array_flip($allowed), | |
| ); | |
| if (empty($data['email']) || !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) { | |
| Flight::jsonHalt(['error' => 'Invalid email'], 400); | |
| } | |
| $this->userService->register($data); | |
| Flight::json(null, 201); | |
| } | |
| } | |
| interface MiddlewareInterface | |
| { | |
| public function before(array $params): void; | |
| public function after(array $params): void; | |
| } | |
| abstract class Middleware implements MiddlewareInterface | |
| { | |
| public function before(array $params): void {} | |
| public function after(array $params): void {} | |
| } | |
| class RateLimitMiddleware extends Middleware {} | |
| $container = new \flight\Container(); | |
| $container->singleton(ConnectionConfig::class, static fn() => new ConnectionConfig( | |
| driver: 'mysql', | |
| host: getenv('DB_HOST') ?: 'localhost', | |
| port: (int) getenv('DB_PORT') ?: 3306, | |
| database: getenv('DB_DATABASE'), | |
| username: getenv('DB_USERNAME'), | |
| password: getenv('DB_PASSWORD'), | |
| charset: getenv('DB_CHARSET') ?: 'utf8mb4', | |
| )); | |
| $container->singleton(ConnectionInterface::class, static fn() => new Connection($container->get(ConnectionConfig::class))); | |
| $container->singleton(UserRepository::class, static fn() => new UserRepository($container->get(ConnectionInterface::class))); | |
| $container->singleton(UserService::class, static fn() => new UserService($container->get(UserRepository::class))); | |
| \Flight::registerContainerHandler($container->get(...)); | |
| \Flight::group('/', static function (): void { | |
| \Flight::resource('/users', UserController::class, ['except' => ['create', 'edit']]); | |
| }, [RateLimitMiddleware::class]); | |
| \Flight::start(); |
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); | |
| /** | |
| * Database connection configuration. | |
| * | |
| * Immutable configuration object that stores database connection parameters | |
| * and provides validation. Supports multiple database drivers. | |
| * | |
| * @package Database | |
| */ | |
| readonly class ConnectionConfig | |
| { | |
| /** | |
| * Create a MySQL connection configuration. | |
| * | |
| * @param string $database Database name | |
| * @param string|null $username Database username (defaults to null) | |
| * @param string|null $password Database password (defaults to null) | |
| * @param string $host Database host (defaults to 'localhost') | |
| * @param int $port Database port (defaults to 3306) | |
| * @param string $charset Character set (defaults to 'utf8mb4') | |
| * @param array<string, mixed>|null $options Additional PDO options | |
| * @return self | |
| * @throws \InvalidArgumentException If any parameter fails validation | |
| */ | |
| public static function mysql( | |
| string $database, | |
| ?string $username = null, | |
| #[\SensitiveParameter] | |
| ?string $password = null, | |
| string $host = 'localhost', | |
| int $port = 3306, | |
| string $charset = 'utf8mb4', | |
| ?array $options = null, | |
| ): self { | |
| return new self('mysql', $host, $port, $database, $username, $password, $charset, $options); | |
| } | |
| /** | |
| * Create a new database connection configuration. | |
| * | |
| * @param string $driver Database driver (e.g., 'mysql', 'pgsql') | |
| * @param string $host Database host | |
| * @param int $port Database port | |
| * @param string $database Database name | |
| * @param string $username Database username | |
| * @param string $password Database password | |
| * @param string $charset Character set (defaults to 'utf8mb4') | |
| * @param array<string, mixed>|null $options Additional PDO options | |
| * @throws \InvalidArgumentException If any parameter fails validation | |
| */ | |
| public function __construct( | |
| public string $driver, | |
| public string $host, | |
| public int $port, | |
| public string $database, | |
| public string $username, | |
| #[\SensitiveParameter] | |
| public string $password, | |
| public string $charset = 'utf8mb4', | |
| public ?array $options = null, | |
| ) { | |
| $this->validate(); | |
| } | |
| /** | |
| * Generate the DSN (Data Source Name) string for PDO. | |
| * | |
| * @return string The DSN string in the format: "driver:host=...;port=...;dbname=...;charset=..." | |
| */ | |
| public function getDsn(): string | |
| { | |
| return sprintf( | |
| '%s:host=%s;port=%d;dbname=%s;charset=%s', | |
| $this->driver, | |
| $this->host, | |
| $this->port, | |
| $this->database, | |
| $this->charset, | |
| ); | |
| } | |
| /** | |
| * Validate all configuration parameters. | |
| * | |
| * @return void | |
| * @throws \InvalidArgumentException If any parameter is invalid | |
| */ | |
| private function validate(): void | |
| { | |
| if (empty($this->driver)) { | |
| throw new \InvalidArgumentException('Driver cannot be empty'); | |
| } | |
| if (empty($this->host)) { | |
| throw new \InvalidArgumentException('Host cannot be empty'); | |
| } | |
| if (!filter_var($this->port, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]])) { | |
| throw new \InvalidArgumentException('Port must be between 1 and 65535'); | |
| } | |
| if (empty($this->database)) { | |
| throw new \InvalidArgumentException('Database cannot be empty'); | |
| } | |
| if (empty($this->username)) { | |
| throw new \InvalidArgumentException('Username cannot be empty'); | |
| } | |
| if (empty($this->password)) { | |
| throw new \InvalidArgumentException('Password cannot be empty'); | |
| } | |
| if (empty($this->charset)) { | |
| throw new \InvalidArgumentException('Charset cannot be empty'); | |
| } | |
| } | |
| } | |
| /** | |
| * Database query result set interface. | |
| * | |
| * Provides methods for iterating over and fetching database query results. | |
| * Results are iterable, countable, and can be fetched in various ways. | |
| * | |
| * @template TRow of array<string, mixed> The shape of a single row | |
| * @extends \IteratorAggregate<int, TRow> | |
| * @package Database | |
| */ | |
| interface ResultInterface extends \IteratorAggregate, \Countable | |
| { | |
| /** | |
| * Fetch the next row from the result set. | |
| * | |
| * @param int $mode The fetch mode. See PDO::FETCH_* constants | |
| * @return TRow|null The next row, or null if there are no more rows | |
| */ | |
| public function fetch(int $mode = \PDO::FETCH_DEFAULT): ?array; | |
| /** | |
| * Fetch all rows from the result set. | |
| * | |
| * @param int $mode The fetch mode. See PDO::FETCH_* constants | |
| * @return array<int, TRow> An array of all rows | |
| */ | |
| public function fetchAll(int $mode = \PDO::FETCH_DEFAULT): array; | |
| /** | |
| * Fetch a single column from the next row. | |
| * | |
| * @param int $column The 0-indexed column number to retrieve | |
| * @return mixed The value from the specified column, or null if no more rows | |
| */ | |
| public function fetchColumn(int $column = 0): mixed; | |
| /** | |
| * Fetch the first row and close the result set. | |
| * | |
| * @return TRow|null The first row, or null if no results | |
| */ | |
| public function first(): ?array; | |
| /** | |
| * Close the result set and free associated resources. | |
| * | |
| * @return void | |
| */ | |
| public function close(): void; | |
| } | |
| /** | |
| * Database query result set implementation. | |
| * | |
| * Wraps a PDOStatement and provides convenient methods for fetching results. | |
| * Automatically closes the cursor when destroyed or when all results are fetched. | |
| * | |
| * @template TRow of array<string, mixed> | |
| * @implements ResultInterface<TRow> | |
| * @package Database | |
| */ | |
| class Result implements ResultInterface | |
| { | |
| /** | |
| * Whether the result set has been closed. | |
| */ | |
| private bool $isClosed = false; | |
| /** | |
| * Create a new result set wrapper. | |
| * | |
| * @param \PDOStatement $stmt The PDO statement to wrap | |
| */ | |
| public function __construct( | |
| private \PDOStatement $stmt, | |
| ) {} | |
| /** | |
| * Get an iterator for the result set. | |
| * | |
| * Automatically closes the result set after iteration completes. | |
| * | |
| * @return \Traversable<int, TRow> | |
| */ | |
| public function getIterator(): \Traversable | |
| { | |
| $this->ensureNotClosed(); | |
| while ($row = $this->stmt->fetch(\PDO::FETCH_ASSOC)) { | |
| yield $row; | |
| } | |
| $this->close(); | |
| } | |
| /** | |
| * Count the number of rows affected by the query. | |
| * | |
| * Note: For SELECT statements, this may not return the actual number of rows. | |
| * Use fetchAll() and count the result for accurate row counts. | |
| * | |
| * @return int The number of rows | |
| */ | |
| public function count(): int | |
| { | |
| $this->ensureNotClosed(); | |
| return $this->stmt->rowCount(); | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function fetch(int $mode = \PDO::FETCH_ASSOC): ?array | |
| { | |
| $this->ensureNotClosed(); | |
| $result = $this->stmt->fetch($mode); | |
| return $result === false ? null : $result; | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function fetchAll(int $mode = \PDO::FETCH_ASSOC): array | |
| { | |
| $this->ensureNotClosed(); | |
| $data = $this->stmt->fetchAll($mode); | |
| $this->close(); | |
| return $data; | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function fetchColumn(int $column = 0): mixed | |
| { | |
| $this->ensureNotClosed(); | |
| $result = $this->stmt->fetchColumn($column); | |
| return $result === false ? null : $result; | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function first(): ?array | |
| { | |
| $row = $this->fetch(); | |
| $this->close(); | |
| return $row; | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function close(): void | |
| { | |
| if (!$this->isClosed) { | |
| $this->stmt->closeCursor(); | |
| $this->isClosed = true; | |
| } | |
| } | |
| /** | |
| * Automatically close the result set when destroyed. | |
| */ | |
| public function __destruct() | |
| { | |
| $this->close(); | |
| } | |
| /** | |
| * Ensure the result set has not been closed. | |
| * | |
| * @return void | |
| * @throws \LogicException If the result set has been closed | |
| */ | |
| private function ensureNotClosed(): void | |
| { | |
| if ($this->isClosed) { | |
| throw new \LogicException('Result set has been closed'); | |
| } | |
| } | |
| } | |
| /** | |
| * Database connection interface. | |
| * | |
| * Provides methods for executing queries, managing transactions, and | |
| * maintaining database connections. Supports nested transactions via savepoints. | |
| * | |
| * @template TRow of array<string, mixed> The shape of a single row | |
| * @package Database | |
| */ | |
| interface ConnectionInterface | |
| { | |
| /** | |
| * Get the underlying PDO instance. | |
| * | |
| * @return \PDO The PDO instance | |
| * @throws \RuntimeException If not connected | |
| */ | |
| public function getPdo(): \PDO; | |
| /** | |
| * Establish a database connection. | |
| * | |
| * @return void | |
| * @throws \RuntimeException If connection fails | |
| */ | |
| public function connect(): void; | |
| /** | |
| * Close the database connection. | |
| * | |
| * @return void | |
| */ | |
| public function disconnect(): void; | |
| /** | |
| * Check if the connection is healthy and responsive. | |
| * | |
| * @return bool True if the connection is healthy, false otherwise | |
| */ | |
| public function isHealthy(): bool; | |
| /** | |
| * Execute a query and return a result set. | |
| * | |
| * @param string $sql The SQL query to execute | |
| * @param array<string|int, mixed> $params Parameters to bind to the query | |
| * @return ResultInterface<TRow> The query result set | |
| * @throws \RuntimeException If the query fails | |
| */ | |
| public function query(string $sql, array $params = []): ResultInterface; | |
| /** | |
| * Execute a statement and return the number of affected rows. | |
| * | |
| * @param string $sql The SQL statement to execute | |
| * @param array<string|int, mixed> $params Parameters to bind to the statement | |
| * @return int The number of affected rows | |
| * @throws \RuntimeException If the statement fails | |
| */ | |
| public function execute(string $sql, array $params = []): int; | |
| /** | |
| * Execute a query and return the first row. | |
| * | |
| * @param string $sql The SQL query to execute | |
| * @param array<string|int, mixed> $params Parameters to bind to the query | |
| * @return TRow|null An associative array representing the first row, or null if no results | |
| */ | |
| public function fetchOne(string $sql, array $params = []): ?array; | |
| /** | |
| * Execute a query and return all rows. | |
| * | |
| * @param string $sql The SQL query to execute | |
| * @param array<string|int, mixed> $params Parameters to bind to the query | |
| * @return array<int, TRow> An array of associative arrays representing all rows | |
| */ | |
| public function fetchAll(string $sql, array $params = []): array; | |
| /** | |
| * Execute a callback within a database transaction. | |
| * | |
| * Supports nested transactions via savepoints. If the callback throws an exception, | |
| * the transaction is rolled back automatically. | |
| * | |
| * @param callable(self): mixed $callback The closure to execute within the transaction | |
| * @return mixed The return value of the callback | |
| * @throws \Throwable If the callback throws an exception (after rollback) | |
| */ | |
| public function transaction(callable $callback): mixed; | |
| /** | |
| * Get the ID of the last inserted row. | |
| * | |
| * @param string|null $name Name of the sequence object (for PostgreSQL) | |
| * @return string The last insert ID | |
| */ | |
| public function lastInsertId(?string $name = null): string; | |
| } | |
| /** | |
| * Database connection implementation. | |
| * | |
| * Manages a PDO connection with support for transactions, nested transactions via | |
| * savepoints, and connection health checking. Uses persistent connections by default | |
| * for improved performance in traditional PHP environments (Apache/FPM). | |
| * | |
| * @template TRow of array<string, mixed> | |
| * @implements ConnectionInterface<TRow> | |
| * @package Database | |
| */ | |
| class Connection implements ConnectionInterface | |
| { | |
| /** | |
| * The PDO instance, or null if not connected. | |
| */ | |
| private ?\PDO $pdo = null; | |
| /** | |
| * Current transaction nesting depth. | |
| */ | |
| private int $transactionDepth = 0; | |
| /** | |
| * Whether the connection is currently established. | |
| */ | |
| public bool $isConnected { | |
| get => $this->pdo !== null; | |
| } | |
| /** | |
| * Create a new database connection. | |
| * | |
| * Automatically connects to the database on instantiation. | |
| * | |
| * @param ConnectionConfig $config The connection configuration | |
| * @throws \RuntimeException If the connection fails | |
| */ | |
| public function __construct( | |
| private readonly ConnectionConfig $config, | |
| ) { | |
| $this->connect(); | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function lastInsertId(?string $name = null): string | |
| { | |
| return $this->getPdo()->lastInsertId($name); | |
| } | |
| /** | |
| * {@inheritDoc} | |
| * | |
| * @throws \RuntimeException If already in a transaction (for non-nested calls) | |
| */ | |
| public function transaction(callable $callback): mixed | |
| { | |
| $pdo = $this->getPdo(); | |
| $this->transactionDepth++; | |
| $savepointName = 'SAVEPOINT_' . $this->transactionDepth; | |
| try { | |
| if ($this->transactionDepth === 1) { | |
| $pdo->beginTransaction(); | |
| } else { | |
| $pdo->exec("SAVEPOINT {$savepointName}"); | |
| } | |
| $result = $callback($this); | |
| if ($this->transactionDepth === 1) { | |
| $pdo->commit(); | |
| } | |
| $this->transactionDepth--; | |
| return $result; | |
| } catch (\Throwable $e) { | |
| if ($this->transactionDepth === 1) { | |
| $pdo->rollBack(); | |
| } else { | |
| $pdo->exec("ROLLBACK TO SAVEPOINT {$savepointName}"); | |
| } | |
| $this->transactionDepth--; | |
| throw $e; | |
| } | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function query(string $sql, array $params = []): ResultInterface | |
| { | |
| $pdo = $this->getPdo(); | |
| try { | |
| $stmt = $pdo->prepare($sql); | |
| if (!$stmt->execute($params)) { | |
| [$sqlStateErrorCode,, $errorMessage] = $stmt->errorInfo(); | |
| throw new \RuntimeException( | |
| "Query failed: {$errorMessage} (SQLSTATE: {$sqlStateErrorCode})" | |
| ); | |
| } | |
| return new Result($stmt); | |
| } catch (\PDOException $e) { | |
| throw new \RuntimeException( | |
| "Database query failed: {$e->getMessage()}", | |
| (int) $e->getCode(), | |
| $e | |
| ); | |
| } | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function execute(string $sql, array $params = []): int | |
| { | |
| $pdo = $this->getPdo(); | |
| try { | |
| $stmt = $pdo->prepare($sql); | |
| $stmt->execute($params); | |
| return $stmt->rowCount(); | |
| } catch (\PDOException $e) { | |
| throw new \RuntimeException( | |
| "Database execute failed: {$e->getMessage()}", | |
| (int) $e->getCode(), | |
| $e | |
| ); | |
| } | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function fetchOne(string $sql, array $params = []): ?array | |
| { | |
| return $this->query($sql, $params)->first(); | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function fetchAll(string $sql, array $params = []): array | |
| { | |
| return $this->query($sql, $params)->fetchAll(); | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function getPdo(): \PDO | |
| { | |
| if (!$this->isConnected) { | |
| throw new \RuntimeException('Not connected to the database'); | |
| } | |
| return $this->pdo; | |
| } | |
| /** | |
| * {@inheritDoc} | |
| * | |
| * Configures PDO with sensible defaults: | |
| * - Exception error mode | |
| * - Associative array fetch mode | |
| * - Real prepared statements (no emulation) | |
| * - Persistent connections enabled by default | |
| * - 10 second connection timeout | |
| */ | |
| public function connect(): void | |
| { | |
| if ($this->isConnected) { | |
| return; | |
| } | |
| $options = $this->config->options ?? []; | |
| $options[\PDO::ATTR_ERRMODE] ??= \PDO::ERRMODE_EXCEPTION; | |
| $options[\PDO::ATTR_DEFAULT_FETCH_MODE] ??= \PDO::FETCH_ASSOC; | |
| $options[\PDO::ATTR_EMULATE_PREPARES] ??= false; | |
| $options[\PDO::ATTR_STRINGIFY_FETCHES] ??= false; | |
| $options[\PDO::ATTR_TIMEOUT] ??= 10; | |
| $options[\PDO::ATTR_PERSISTENT] ??= true; | |
| try { | |
| $this->pdo = new \PDO( | |
| $this->config->getDsn(), | |
| $this->config->username, | |
| $this->config->password, | |
| $options, | |
| ); | |
| } catch (\PDOException $e) { | |
| throw new \RuntimeException( | |
| "Failed to connect to database: {$e->getMessage()}", | |
| (int) $e->getCode(), | |
| $e | |
| ); | |
| } | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function disconnect(): void | |
| { | |
| if (!$this->isConnected) { | |
| return; | |
| } | |
| $this->pdo = null; | |
| $this->transactionDepth = 0; | |
| } | |
| /** | |
| * {@inheritDoc} | |
| */ | |
| public function isHealthy(): bool | |
| { | |
| if (!$this->isConnected) { | |
| return false; | |
| } | |
| try { | |
| $this->pdo->query('SELECT 1'); | |
| return true; | |
| } catch (\PDOException) { | |
| return false; | |
| } | |
| } | |
| /** | |
| * Automatically disconnect when the object is destroyed. | |
| */ | |
| public function __destruct() | |
| { | |
| $this->disconnect(); | |
| } | |
| } | |
| /** | |
| * Pagination result container. | |
| * | |
| * Encapsulates paginated data along with metadata about the pagination state. | |
| * Implements JsonSerializable for easy conversion to JSON responses in APIs. | |
| * | |
| * @template TItem The type of items in the collection | |
| * @package Database | |
| */ | |
| class Paginator implements \JsonSerializable | |
| { | |
| /** | |
| * Create a new paginator instance. | |
| * | |
| * @param array<int, TItem> $items The items for the current page | |
| * @param int $total Total number of items across all pages | |
| * @param int $currentPage Current page number (1-indexed) | |
| * @param int $perPage Number of items per page | |
| */ | |
| public function __construct( | |
| private readonly array $items, | |
| private readonly int $total, | |
| private readonly int $currentPage, | |
| private readonly int $perPage, | |
| ) {} | |
| /** | |
| * Get the items for the current page. | |
| * | |
| * @return array<int, TItem> | |
| */ | |
| public function items(): array | |
| { | |
| return $this->items; | |
| } | |
| /** | |
| * Get the total number of items across all pages. | |
| * | |
| * @return int | |
| */ | |
| public function total(): int | |
| { | |
| return $this->total; | |
| } | |
| /** | |
| * Get the current page number (1-indexed). | |
| * | |
| * @return int | |
| */ | |
| public function currentPage(): int | |
| { | |
| return $this->currentPage; | |
| } | |
| /** | |
| * Get the number of items per page. | |
| * | |
| * @return int | |
| */ | |
| public function perPage(): int | |
| { | |
| return $this->perPage; | |
| } | |
| /** | |
| * Get the total number of pages. | |
| * | |
| * @return int | |
| */ | |
| public function lastPage(): int | |
| { | |
| return (int) ceil($this->total / $this->perPage); | |
| } | |
| /** | |
| * Get the index of the first item on the current page (1-indexed). | |
| * | |
| * @return int | |
| */ | |
| public function from(): int | |
| { | |
| if ($this->total === 0) { | |
| return 0; | |
| } | |
| return (($this->currentPage - 1) * $this->perPage) + 1; | |
| } | |
| /** | |
| * Get the index of the last item on the current page (1-indexed). | |
| * | |
| * @return int | |
| */ | |
| public function to(): int | |
| { | |
| if ($this->total === 0) { | |
| return 0; | |
| } | |
| return min($this->currentPage * $this->perPage, $this->total); | |
| } | |
| /** | |
| * Determine if there is a previous page. | |
| * | |
| * @return bool | |
| */ | |
| public function hasPreviousPage(): bool | |
| { | |
| return $this->currentPage > 1; | |
| } | |
| /** | |
| * Determine if there is a next page. | |
| * | |
| * @return bool | |
| */ | |
| public function hasNextPage(): bool | |
| { | |
| return $this->currentPage < $this->lastPage(); | |
| } | |
| /** | |
| * Get the previous page number, or null if on first page. | |
| * | |
| * @return int|null | |
| */ | |
| public function previousPage(): ?int | |
| { | |
| return $this->hasPreviousPage() ? $this->currentPage - 1 : null; | |
| } | |
| /** | |
| * Get the next page number, or null if on last page. | |
| * | |
| * @return int|null | |
| */ | |
| public function nextPage(): ?int | |
| { | |
| return $this->hasNextPage() ? $this->currentPage + 1 : null; | |
| } | |
| /** | |
| * Determine if the current page is empty. | |
| * | |
| * @return bool | |
| */ | |
| public function isEmpty(): bool | |
| { | |
| return empty($this->items); | |
| } | |
| /** | |
| * Determine if the current page is not empty. | |
| * | |
| * @return bool | |
| */ | |
| public function isNotEmpty(): bool | |
| { | |
| return !$this->isEmpty(); | |
| } | |
| /** | |
| * Get the number of items on the current page. | |
| * | |
| * @return int | |
| */ | |
| public function count(): int | |
| { | |
| return count($this->items); | |
| } | |
| /** | |
| * Serialize the paginator to an array suitable for JSON encoding. | |
| * | |
| * @return array{ | |
| * data: array<int, TItem>, | |
| * meta: array{ | |
| * total: int, | |
| * per_page: int, | |
| * current_page: int, | |
| * last_page: int, | |
| * from: int, | |
| * to: int | |
| * }, | |
| * links: array{ | |
| * first: int, | |
| * last: int, | |
| * prev: int|null, | |
| * next: int|null | |
| * } | |
| * } | |
| */ | |
| public function jsonSerialize(): array | |
| { | |
| return [ | |
| 'data' => $this->items, | |
| 'meta' => [ | |
| 'total' => $this->total, | |
| 'per_page' => $this->perPage, | |
| 'current_page' => $this->currentPage, | |
| 'last_page' => $this->lastPage(), | |
| 'from' => $this->from(), | |
| 'to' => $this->to(), | |
| ], | |
| 'links' => [ | |
| 'first' => 1, | |
| 'last' => $this->lastPage(), | |
| 'prev' => $this->previousPage(), | |
| 'next' => $this->nextPage(), | |
| ], | |
| ]; | |
| } | |
| /** | |
| * Transform the items in the paginator using a callback. | |
| * | |
| * @template TNewItem | |
| * @param callable(TItem): TNewItem $callback Transformation callback | |
| * @return Paginator<TNewItem> A new paginator with transformed items | |
| */ | |
| public function map(callable $callback): self | |
| { | |
| return new self( | |
| items: array_map($callback, $this->items), | |
| total: $this->total, | |
| currentPage: $this->currentPage, | |
| perPage: $this->perPage, | |
| ); | |
| } | |
| /** | |
| * Create a paginator from a query result and total count. | |
| * | |
| * @template T | |
| * @param array<int, T> $items The items for the current page | |
| * @param int $total Total number of items | |
| * @param int $page Current page number (1-indexed) | |
| * @param int $perPage Items per page | |
| * @return self<T> | |
| */ | |
| public static function make(array $items, int $total, int $page, int $perPage): self | |
| { | |
| return new self($items, $total, $page, $perPage); | |
| } | |
| } | |
| /** | |
| * Base repository with common database operations. | |
| * | |
| * Provides helper methods for queries, executions, and fetches. | |
| * Extend this class to create specific repositories. | |
| * | |
| * @template TRow of array<string, mixed> | |
| * @package Database | |
| */ | |
| abstract class BaseRepository | |
| { | |
| /** | |
| * Create a new repository instance. | |
| * | |
| * @param ConnectionInterface<TRow> $connection The database connection | |
| */ | |
| public function __construct( | |
| protected readonly ConnectionInterface $connection, | |
| ) {} | |
| /** | |
| * Execute a query and return a result set. | |
| * | |
| * @param string $sql The SQL query | |
| * @param array<string|int, mixed> $params Query parameters | |
| * @return ResultInterface<TRow> The result set | |
| */ | |
| protected function query(string $sql, array $params = []): ResultInterface | |
| { | |
| return $this->connection->query($sql, $params); | |
| } | |
| /** | |
| * Execute a statement and return affected row count. | |
| * | |
| * @param string $sql The SQL statement | |
| * @param array<string|int, mixed> $params Statement parameters | |
| * @return int Number of affected rows | |
| */ | |
| protected function execute(string $sql, array $params = []): int | |
| { | |
| return $this->connection->execute($sql, $params); | |
| } | |
| /** | |
| * Fetch a single row. | |
| * | |
| * @param string $sql The SQL query | |
| * @param array<string|int, mixed> $params Query parameters | |
| * @return TRow|null The first row, or null if no results | |
| */ | |
| protected function fetchOne(string $sql, array $params = []): ?array | |
| { | |
| return $this->connection->fetchOne($sql, $params); | |
| } | |
| /** | |
| * Fetch all rows. | |
| * | |
| * @param string $sql The SQL query | |
| * @param array<string|int, mixed> $params Query parameters | |
| * @return array<int, TRow> All rows | |
| */ | |
| protected function fetchAll(string $sql, array $params = []): array | |
| { | |
| return $this->connection->fetchAll($sql, $params); | |
| } | |
| /** | |
| * Paginate query results. | |
| * | |
| * Executes two queries: one for the total count and one for the paginated data. | |
| * | |
| * @param string $sql The base SQL query (without LIMIT/OFFSET) | |
| * @param array<string|int, mixed> $params Query parameters | |
| * @param int $page Current page number (1-indexed, minimum 1) | |
| * @param int $perPage Number of items per page (minimum 1, maximum 100) | |
| * @return Paginator<TRow> The paginated result | |
| * @throws \InvalidArgumentException If page or perPage parameters are invalid | |
| */ | |
| protected function paginate(string $sql, array $params = [], int $page = 1, int $perPage = 15): Paginator | |
| { | |
| // Validate pagination parameters | |
| if ($page < 1) { | |
| throw new \InvalidArgumentException('Page number must be at least 1'); | |
| } | |
| if ($perPage < 1 || $perPage > 100) { | |
| throw new \InvalidArgumentException('Per page value must be between 1 and 100'); | |
| } | |
| // Get total count | |
| $countSql = $this->buildCountQuery($sql); | |
| $total = (int) $this->connection->fetchOne($countSql, $params)['total'] ?? 0; | |
| // If total is 0, return empty paginator | |
| if ($total === 0) { | |
| return Paginator::make([], 0, $page, $perPage); | |
| } | |
| // Calculate offset | |
| $offset = ($page - 1) * $perPage; | |
| // Fetch paginated data | |
| $paginatedSql = $sql . " LIMIT {$perPage} OFFSET {$offset}"; | |
| $items = $this->fetchAll($paginatedSql, $params); | |
| return Paginator::make($items, $total, $page, $perPage); | |
| } | |
| /** | |
| * Build a COUNT query from a SELECT query. | |
| * | |
| * Extracts the FROM clause and WHERE conditions to create an efficient count query. | |
| * Handles basic SELECT queries with FROM, WHERE, and JOIN clauses. | |
| * | |
| * @param string $sql The original SELECT query | |
| * @return string The COUNT query | |
| */ | |
| protected function buildCountQuery(string $sql): string | |
| { | |
| // Try to extract FROM clause (everything after SELECT ... FROM up to ORDER BY/LIMIT/GROUP BY) | |
| if (preg_match('/\bFROM\b(.+?)(?:\bORDER BY\b|\bLIMIT\b|\bGROUP BY\b|$)/is', $sql, $matches)) { | |
| $fromClause = trim($matches[1]); | |
| return "SELECT COUNT(*) as total FROM {$fromClause}"; | |
| } | |
| // Fallback: wrap the entire query | |
| return "SELECT COUNT(*) as total FROM ({$sql}) as count_table"; | |
| } | |
| } | |
| /** | |
| * User repository for managing user data. | |
| * | |
| * Provides methods for creating, updating, and retrieving user records | |
| * with support for pagination. | |
| * | |
| * @template TRow of array{id: int, name: string, email: string, created_at?: string, updated_at?: string} | |
| * @extends BaseRepository<TRow> | |
| * @package Repository | |
| */ | |
| class UserRepository extends BaseRepository | |
| { | |
| /** | |
| * Retrieve all users from the database. | |
| * | |
| * @return array<int, TRow> Array of all user records | |
| * @throws \RuntimeException If the query fails | |
| */ | |
| public function findAll(): array | |
| { | |
| return $this->fetchAll( | |
| sql: 'SELECT u.* FROM users AS u', | |
| ); | |
| } | |
| /** | |
| * Retrieve paginated users from the database. | |
| * | |
| * @param int $page Current page number (1-indexed, default: 1) | |
| * @param int $perPage Number of items per page (default: 15, max: 100) | |
| * @return Paginator<TRow> Paginated user results | |
| * @throws \InvalidArgumentException If pagination parameters are invalid | |
| * @throws \RuntimeException If the query fails | |
| */ | |
| public function paginate( | |
| string $sql = 'SELECT u.* FROM users AS u ORDER BY u.id DESC', | |
| array $params = [], | |
| int $page = 1, | |
| int $perPage = 15 | |
| ): Paginator { | |
| return parent::paginate( | |
| sql: $sql, | |
| params: $params, | |
| page: $page, | |
| perPage: $perPage, | |
| ); | |
| } | |
| /** | |
| * Create a new user or update existing user if email already exists. | |
| * | |
| * Uses MySQL's ON DUPLICATE KEY UPDATE to handle conflicts on the email field. | |
| * The LAST_INSERT_ID(id) trick ensures the correct ID is returned whether | |
| * inserting a new row or updating an existing one. | |
| * | |
| * @param string $name The user's name | |
| * @param string $email The user's email address (must be unique) | |
| * @return int The user ID (new or existing) | |
| * @throws \RuntimeException If the operation fails | |
| */ | |
| public function createOrUpdate(string $name, string $email): int | |
| { | |
| $this->execute( | |
| sql: <<<SQL | |
| INSERT INTO users (`name`, `email`) | |
| VALUES (:name, :email) | |
| ON DUPLICATE KEY UPDATE | |
| id = LAST_INSERT_ID(id), | |
| `name` = VALUES(`name`), | |
| updated_at = NOW() | |
| SQL, | |
| params: [ | |
| 'name' => $name, | |
| 'email' => $email, | |
| ], | |
| ); | |
| return (int) $this->connection->lastInsertId(); | |
| } | |
| } | |
| /** | |
| * User service for business logic operations. | |
| * | |
| * Handles user registration and other user-related operations with | |
| * proper transaction management. | |
| * | |
| * @package Service | |
| */ | |
| class UserService | |
| { | |
| /** | |
| * Create a new user service instance. | |
| * | |
| * @param ConnectionInterface $connection The database connection | |
| * @param UserRepository $userRepository The user repository | |
| */ | |
| public function __construct( | |
| private readonly ConnectionInterface $connection, | |
| private readonly UserRepository $userRepository, | |
| ) {} | |
| /** | |
| * Register a new user or update existing user. | |
| * | |
| * Executes the operation within a transaction to ensure data consistency. | |
| * If the email already exists, updates the user's name instead of failing. | |
| * | |
| * @param string $name The user's name | |
| * @param string $email The user's email address | |
| * @return int The user ID (new or existing) | |
| * @throws \RuntimeException If the registration fails | |
| */ | |
| public function registerUser(string $name, string $email): int | |
| { | |
| return $this->connection->transaction( | |
| function () use ($name, $email): int { | |
| $userId = $this->userRepository->createOrUpdate($name, $email); | |
| // Additional transactional operations can be added here | |
| // e.g., create user profile, send welcome email, audit log | |
| return $userId; | |
| } | |
| ); | |
| } | |
| } | |
| // Bootstrap application | |
| $connection = new Connection( | |
| config: ConnectionConfig::mysql( | |
| database: getenv('DB_DATABASE') ?: throw new \RuntimeException('DB_DATABASE not set'), | |
| username: getenv('DB_USERNAME') ?: throw new \RuntimeException('DB_USERNAME not set'), | |
| password: getenv('DB_PASSWORD') ?: throw new \RuntimeException('DB_PASSWORD not set'), | |
| host: getenv('DB_HOST') ?: 'localhost', | |
| ), | |
| ); | |
| $userRepository = new UserRepository($connection); | |
| $paginator = $userRepository->paginate(page: 2, perPage: 5); | |
| $userService = new UserService( | |
| connection: $connection, | |
| userRepository: $userRepository, | |
| ); | |
| // Example usage | |
| $userId = $userService->registerUser( | |
| name: 'Jane Smith', | |
| email: '[email protected]', | |
| ); | |
| dump(json_decode(json_encode($paginator), true)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment