PDOService.php
PHP
Path: src/Database/PDOService.php
<?php
namespace mini\Database;
use mini\Mini;
use mini\Exceptions\ConfigurationRequiredException;
/**
* PDO Service Factory
*
* Provides configured PDO instances with smart defaults:
* - UTF-8 encoding based on PHP's default_charset
* - Timezone configuration from Mini
* - Exception error mode
* - Associative array fetch mode
*/
class PDOService
{
/**
* Create and configure PDO instance
*
* Loads PDO instance from config (with fallback to auto SQLite),
* then applies standard configuration.
*
* Config file: _config/PDO.php
*/
public static function factory(): \PDO
{
// Load PDO instance from config (application first, framework fallback)
$pdo = Mini::$mini->loadServiceConfig(\PDO::class);
if (!($pdo instanceof \PDO)) {
throw new \RuntimeException('_config/PDO.php must return a PDO instance');
}
// Always apply standard configuration
self::configure($pdo);
return $pdo;
}
/**
* Configure PDO instance with framework defaults
*
* Applies consistent configuration regardless of how PDO was instantiated:
* - Exception error mode
* - Associative array fetch mode
* - UTF-8 charset
* - UTC timezone
*/
public static function configure(\PDO $pdo): void
{
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$pdo->setAttribute(\PDO::ATTR_DEFAULT_FETCH_MODE, \PDO::FETCH_ASSOC);
$sqlTimezone = Mini::$mini->sqlTimezone;
// Configure charset (UTF-8) and timezone based on driver
switch ($pdo->getAttribute(\PDO::ATTR_DRIVER_NAME)) {
case 'mysql':
$pdo->exec("SET NAMES utf8mb4, time_zone = '{$sqlTimezone}'");
break;
case 'pgsql':
$pdo->exec("SET client_encoding TO 'UTF8', timezone TO '{$sqlTimezone}'");
break;
case 'oci':
$pdo->exec("ALTER SESSION SET TIME_ZONE = '{$sqlTimezone}'");
break;
case 'sqlite':
$pdo->exec("PRAGMA encoding = 'UTF-8'");
// SQLite has no timezone concept - stores raw values
break;
case 'sqlsrv':
case 'dblib':
// SQL Server has no session timezone - uses server OS timezone.
// Verify the server timezone matches sqlTimezone (cached briefly).
// Short TTL (30s) because server timezone offset can change with DST transitions.
$cacheKey = 'mini:sqlsrv_tz:' . md5(($pdo->getAttribute(\PDO::ATTR_CONNECTION_STATUS) ?? 'default') . $sqlTimezone);
$matches = apcu_fetch($cacheKey, $found);
if (!$found) {
$serverOffset = (int)$pdo->query("SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())")->fetchColumn();
$expectedOffset = self::parseTimezoneOffset($sqlTimezone);
$matches = ($serverOffset === $expectedOffset);
apcu_store($cacheKey, $matches, 30);
}
if (!$matches) {
throw new \RuntimeException(
"SQL Server timezone does not match configured sqlTimezone '{$sqlTimezone}'. " .
"SQL Server uses the OS timezone and cannot be changed per-connection. " .
"Either configure the server OS timezone or set SQL_TIMEZONE to match the server."
);
}
break;
}
}
/**
* Parse timezone offset string to minutes
*
* @param string $offset Offset like '+00:00', '-05:00', '+05:30'
* @return int Offset in minutes from UTC
*/
private static function parseTimezoneOffset(string $offset): int
{
if (!preg_match('/^([+-])(\d{2}):(\d{2})$/', $offset, $m)) {
throw new \InvalidArgumentException(
"Invalid sqlTimezone format '{$offset}'. Use offset format like '+00:00', '-05:00'."
);
}
$minutes = ((int)$m[2] * 60) + (int)$m[3];
return $m[1] === '-' ? -$minutes : $minutes;
}
/**
* Create default SQLite database (used by framework's config/pdo.php fallback)
*
* Auto-creates SQLite database at ROOT/_database.sqlite3 with security checks.
* Applications can call this from their own config if they want the same behavior.
*/
public static function createDefaultSqlite(): \PDO
{
// Check if SQLite extension is available
if (!extension_loaded('pdo_sqlite')) {
throw new ConfigurationRequiredException(
'PDO.php',
'database connection (SQLite extension not available for auto-configuration)'
);
}
$dbPath = Mini::$mini->root . '/_database.sqlite3';
// Security check: ensure database is NOT in document root
if (Mini::$mini->docRoot !== null) {
$realDbDir = realpath(dirname($dbPath)) ?: dirname($dbPath);
$realDocRoot = realpath(Mini::$mini->docRoot);
if (str_starts_with($realDbDir, $realDocRoot)) {
throw new ConfigurationRequiredException(
'PDO.php',
'database connection (auto-created SQLite database would be web-accessible)'
);
}
}
// Create and return PDO instance (configuration will be applied by factory())
return new \PDO('sqlite:' . $dbPath);
}
}