diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0099258 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +APP_ENV=local + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=shift +DB_USERNAME=root +DB_PASSWORD= +DB_CHARSET=utf8mb4 diff --git a/.gitignore b/.gitignore index 2680b06..787214c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /nbproject/private/ /vendor/ +/.env Engine/storage/views/ diff --git a/README.md b/README.md index e0e65a6..18827d7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ The current architecture focuses on an API-only modular monolith: - controller actions, - JSON responses, - request helpers, +- environment configuration, +- PDO database queries, - middleware pipeline, - validation helpers and typed request DTOs, - JSON error responses, @@ -20,6 +22,7 @@ The current architecture focuses on an API-only modular monolith: - PHP 8.3 or higher - Composer +- `json` and `pdo` PHP extensions ## Routing @@ -226,6 +229,46 @@ $app->middleware(function (Request $request, callable $next): Response { Built-in middleware includes `Shift\Middleware\CorsMiddleware`, `Shift\Middleware\AuthMiddleware`, and `Shift\Middleware\AuthorizationMiddleware`. Auth middleware uses `Shift\Auth\AuthenticatorInterface`; authorization middleware uses `Shift\Auth\AuthorizerInterface`. +## Environment + +ShiftPHP loads `.env` from the project root during bootstrap. Use `.env.example` as the starting point: + +```env +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=shift +DB_USERNAME=root +DB_PASSWORD= +DB_CHARSET=utf8mb4 +``` + +Existing server environment variables are not overwritten by `.env`. + +## Database + +Database access uses native PDO and is registered lazily in the service container as `Shift\Database\Database` and `db`: + +```php +use Shift\Database\Database; + +class UserService +{ + public function __construct(private readonly Database $db) + { + } + + public function find(int $id): ?array + { + return $this->db + ->query('select * from users where id = :id', ['id' => $id]) + ->first(); + } +} +``` + +Available helpers are `query($sql, $parameters)`, `execute($sql, $parameters)`, `pdo()`, and `transaction($callback)`. Query results expose `all()`, `first()`, `value()`, `affectedRows()`, and the raw `PDOStatement`. + ## Service Container The container can resolve registered services and build classes with typed constructor dependencies: diff --git a/REFACTORING.md b/REFACTORING.md index 54ed3be..0642c4b 100644 --- a/REFACTORING.md +++ b/REFACTORING.md @@ -27,6 +27,8 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled - [x] Module lifecycle hooks, for example `boot()` after service registration. - [x] Framework source moved to `src/` for package split preparation. - [x] CLI create generators for modules and module-owned classes with file-based stubs. +- [x] `.env` loading for application configuration. +- [x] Native PDO database configuration, lazy connection, and basic query API. - [x] Removal of view storage and example page assets from runtime. - [x] Removal of legacy `application/controllers` and `application/routes.php`. - [x] Domain-oriented framework namespaces: diff --git a/bootstrap.php b/bootstrap.php index 7b0a76b..9611cb6 100755 --- a/bootstrap.php +++ b/bootstrap.php @@ -2,6 +2,7 @@ // Define application constants use Shift\App; +use Shift\Config\EnvLoader; use Shift\Error\ErrorHandler; define('APP_ROOT', realpath(__DIR__ . '/')); @@ -12,6 +13,9 @@ // Load Composer autoloader require_once VENDOR_PATH . '/autoload.php'; +// Load environment variables +(new EnvLoader())->load(APP_ROOT . '/.env'); + // Register custom autoloader spl_autoload_register(['Shift\App', 'autoload']); diff --git a/composer.json b/composer.json index 3443953..52d5dfc 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "minimum-stability": "dev", "require": { "php": ">=8.3", - "ext-json": "*" + "ext-json": "*", + "ext-pdo": "*" }, "scripts": { "test": "XDEBUG_MODE=off php tests/ApiCoreTest.php" diff --git a/docs/index.html b/docs/index.html index 9439dd4..583ff63 100644 --- a/docs/index.html +++ b/docs/index.html @@ -25,6 +25,7 @@

Contents

  • Validation and DTOs
  • Middleware
  • Service Container
  • +
  • Environment and Database
  • CLI
  • Errors
  • Testing
  • @@ -38,6 +39,7 @@

    Requirements

  • PHP 8.3 or newer
  • Composer
  • The json PHP extension
  • +
  • The pdo PHP extension
  • @@ -71,6 +73,10 @@

    Core Namespaces

    Module contracts and module loader.
    Shift\Service
    Small service container and service interface.
    +
    Shift\Config
    +
    Environment variable loading and lookup.
    +
    Shift\Database
    +
    PDO database configuration, connection, and query helpers.
    Shift\Console
    CLI command dispatcher and built-in commands.
    Shift\Error
    @@ -383,7 +389,40 @@

    Service Container

    $controller = $container->make(HealthController::class); $exists = $container->has(HealthService::class); -

    The app registers the current request and router as default singleton services under request, Shift\Request, router, and Shift\Routing\Router\Router. It also registers the current Shift\Service\ServiceContainer instance.

    +

    The app registers the current request and router as default singleton services under request, Shift\Request, router, and Shift\Routing\Router\Router. It also registers the current Shift\Service\ServiceContainer instance, database config, and a lazy database service.

    + + +
    +

    Environment and Database

    +

    bootstrap.php loads .env from the project root without overwriting variables that already exist in the server environment.

    + +
    DB_CONNECTION=mysql
    +DB_HOST=127.0.0.1
    +DB_PORT=3306
    +DB_DATABASE=shift
    +DB_USERNAME=root
    +DB_PASSWORD=
    +DB_CHARSET=utf8mb4
    + +

    Database access uses native PDO. The app registers Shift\Database\DatabaseConfig, Shift\Database\Database, and the db alias lazily in the container.

    + +
    use Shift\Database\Database;
    +
    +class UserService
    +{
    +    public function __construct(private readonly Database $db)
    +    {
    +    }
    +
    +    public function find(int $id): ?array
    +    {
    +        return $this->db
    +            ->query('select * from users where id = :id', ['id' => $id])
    +            ->first();
    +    }
    +}
    + +

    Use query() for prepared queries, execute() for write statements, pdo() for raw PDO access, and transaction() for transactional work.

    diff --git a/src/App.php b/src/App.php index 150577e..930a27c 100755 --- a/src/App.php +++ b/src/App.php @@ -3,6 +3,8 @@ namespace Shift; use Shift\Error\HttpError; +use Shift\Database\Database; +use Shift\Database\DatabaseConfig; use Shift\Middleware\MiddlewareInterface; use Shift\Middleware\MiddlewarePipeline; use Shift\Response\JsonResponse; @@ -360,6 +362,11 @@ private function registerDefaultServices(): void $this->container->singleton('router', $this->router); $this->container->singleton(Router::class, $this->router); $this->container->singleton(ServiceContainer::class, $this->container); + $this->container->singleton(DatabaseConfig::class, fn (): DatabaseConfig => DatabaseConfig::fromEnv()); + $this->container->singleton(Database::class, fn (ServiceContainer $container): Database => new Database( + $container->resolve(DatabaseConfig::class) + )); + $this->container->singleton('db', fn (ServiceContainer $container): Database => $container->resolve(Database::class)); } public function getContainer(): ServiceContainer diff --git a/src/Config/Env.php b/src/Config/Env.php new file mode 100644 index 0000000..9bfbd26 --- /dev/null +++ b/src/Config/Env.php @@ -0,0 +1,26 @@ +put($key, $this->parseValue(trim($value))); + } + } + + private function put(string $key, string $value): void + { + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + putenv($key . '=' . $value); + } + + private function parseValue(string $value): string + { + if ($value === '') { + return ''; + } + + $quote = $value[0]; + + if (($quote === '"' || $quote === "'") && str_ends_with($value, $quote)) { + $value = substr($value, 1, -1); + } else { + $value = preg_replace('/\s+#.*$/', '', $value) ?? $value; + } + + return str_replace(['\\n', '\\r'], ["\n", "\r"], $value); + } +} diff --git a/src/Database/Database.php b/src/Database/Database.php new file mode 100644 index 0000000..b030b8e --- /dev/null +++ b/src/Database/Database.php @@ -0,0 +1,82 @@ +pdo instanceof PDO) { + return $this->pdo; + } + + try { + $this->pdo = new PDO( + $this->config->dsn(), + $this->config->username, + $this->config->password, + $this->options() + ); + } catch (PDOException $exception) { + throw new DatabaseException('Database connection failed.', 0, $exception); + } + + return $this->pdo; + } + + public function query(string $sql, array $parameters = []): QueryResult + { + try { + $statement = $this->pdo()->prepare($sql); + + if ($statement === false) { + throw new DatabaseException('Database query could not be prepared.'); + } + + $statement->execute($parameters); + + return new QueryResult($statement); + } catch (PDOException $exception) { + throw new DatabaseException('Database query failed.', 0, $exception); + } + } + + public function execute(string $sql, array $parameters = []): int + { + return $this->query($sql, $parameters)->affectedRows(); + } + + public function transaction(callable $callback): mixed + { + $pdo = $this->pdo(); + $pdo->beginTransaction(); + + try { + $result = $callback($this); + $pdo->commit(); + + return $result; + } catch (\Throwable $exception) { + $pdo->rollBack(); + throw $exception; + } + } + + private function options(): array + { + return $this->config->options + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]; + } +} diff --git a/src/Database/DatabaseConfig.php b/src/Database/DatabaseConfig.php new file mode 100644 index 0000000..cf38934 --- /dev/null +++ b/src/Database/DatabaseConfig.php @@ -0,0 +1,100 @@ +dsn !== null && $this->dsn !== '') { + return $this->dsn; + } + + return match ($this->driver) { + 'sqlite' => 'sqlite:' . ($this->database !== '' ? $this->database : ':memory:'), + 'pgsql' => $this->pgsqlDsn(), + 'sqlsrv' => $this->sqlsrvDsn(), + default => $this->mysqlDsn(), + }; + } + + private static function intOrNull(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + return (int) $value; + } + + private function mysqlDsn(): string + { + $parts = [ + 'host=' . ($this->host ?? '127.0.0.1'), + 'dbname=' . $this->database, + 'charset=' . $this->charset, + ]; + + if ($this->port !== null) { + $parts[] = 'port=' . $this->port; + } + + return 'mysql:' . implode(';', $parts); + } + + private function pgsqlDsn(): string + { + $parts = [ + 'host=' . ($this->host ?? '127.0.0.1'), + 'dbname=' . $this->database, + ]; + + if ($this->port !== null) { + $parts[] = 'port=' . $this->port; + } + + return 'pgsql:' . implode(';', $parts); + } + + private function sqlsrvDsn(): string + { + $server = $this->host ?? '127.0.0.1'; + + if ($this->port !== null) { + $server .= ',' . $this->port; + } + + return 'sqlsrv:Server=' . $server . ';Database=' . $this->database; + } +} diff --git a/src/Database/DatabaseException.php b/src/Database/DatabaseException.php new file mode 100644 index 0000000..dd3b718 --- /dev/null +++ b/src/Database/DatabaseException.php @@ -0,0 +1,9 @@ +statement->fetchAll(PDO::FETCH_ASSOC); + } + + public function first(): ?array + { + $row = $this->statement->fetch(PDO::FETCH_ASSOC); + + return $row === false ? null : $row; + } + + public function value(string|int $column = 0): mixed + { + $row = $this->statement->fetch(PDO::FETCH_ASSOC); + + if ($row === false) { + return null; + } + + if (is_int($column)) { + return array_values($row)[$column] ?? null; + } + + return $row[$column] ?? null; + } + + public function affectedRows(): int + { + return $this->statement->rowCount(); + } + + public function statement(): PDOStatement + { + return $this->statement; + } +} diff --git a/tests/Feature/EnvAndDatabaseTest.php b/tests/Feature/EnvAndDatabaseTest.php new file mode 100644 index 0000000..1d4b184 --- /dev/null +++ b/tests/Feature/EnvAndDatabaseTest.php @@ -0,0 +1,89 @@ + function (): void { + $path = tempnam(sys_get_temp_dir(), 'shift-env-'); + $key = 'SHIFT_TEST_ENV_' . bin2hex(random_bytes(4)); + $quotedKey = $key . '_QUOTED'; + + try { + file_put_contents($path, "{$key}=plain\n{$quotedKey}=\"quoted value\"\n"); + + (new EnvLoader())->load($path); + + assertSameValue('plain', Env::get($key), 'Env loader should expose plain values.'); + assertSameValue('quoted value', Env::get($quotedKey), 'Env loader should unquote quoted values.'); + } finally { + putenv($key); + putenv($quotedKey); + unset($_ENV[$key], $_ENV[$quotedKey], $_SERVER[$key], $_SERVER[$quotedKey]); + + if (is_string($path) && is_file($path)) { + unlink($path); + } + } + }, + 'database config builds dsn from environment' => function (): void { + $prefix = 'SHIFT_TEST_DB_' . bin2hex(random_bytes(4)) . '_'; + + try { + setTestEnv($prefix . 'CONNECTION', 'MySQL'); + setTestEnv($prefix . 'HOST', 'db.local'); + setTestEnv($prefix . 'PORT', '3307'); + setTestEnv($prefix . 'DATABASE', 'shift_test'); + setTestEnv($prefix . 'USERNAME', 'shift_user'); + setTestEnv($prefix . 'PASSWORD', 'secret'); + + $config = DatabaseConfig::fromEnv($prefix); + + assertSameValue('mysql:host=db.local;dbname=shift_test;charset=utf8mb4;port=3307', $config->dsn(), 'MySQL DSN should be built from env.'); + assertSameValue('shift_user', $config->username, 'Username should be read from env.'); + assertSameValue('secret', $config->password, 'Password should be read from env.'); + } finally { + clearTestEnvPrefix($prefix); + } + }, + 'database can execute simple parameterized queries' => function (): void { + $db = new Database(new DatabaseConfig( + driver: 'sqlite', + database: ':memory:' + )); + + $db->execute('create table users (id integer primary key autoincrement, email text not null)'); + $db->execute('insert into users (email) values (:email)', ['email' => 'dev@example.com']); + + $row = $db->query('select id, email from users where email = :email', ['email' => 'dev@example.com'])->first(); + + assertSameValue('dev@example.com', $row['email'] ?? null, 'Database query should fetch inserted rows.'); + }, + 'app registers database services lazily' => function (): void { + $app = new App(makeRequest('GET', '/health')); + + assertSameValue(true, $app->getContainer()->has(Database::class), 'App should register database service.'); + assertSameValue(true, $app->getContainer()->has(DatabaseConfig::class), 'App should register database config service.'); + assertSameValue(true, $app->getContainer()->has('db'), 'App should register db alias.'); + }, +]; + +function setTestEnv(string $key, string $value): void +{ + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + putenv($key . '=' . $value); +} + +function clearTestEnvPrefix(string $prefix): void +{ + foreach (array_keys($_ENV) as $key) { + if (str_starts_with($key, $prefix)) { + unset($_ENV[$key], $_SERVER[$key]); + putenv($key); + } + } +}