diff --git a/.github/workflows/composer-validate.yml b/.github/workflows/composer-validate.yml new file mode 100644 index 0000000..d89a0c3 --- /dev/null +++ b/.github/workflows/composer-validate.yml @@ -0,0 +1,31 @@ +name: Validate composer.json + +on: + push: + paths: + - 'composer.json' + - 'composer.lock' + pull_request: + paths: + - 'composer.json' + - 'composer.lock' + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + tools: composer:v2 + + - name: Validate composer.json + run: composer validate --strict diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..9b8b6a0 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,62 @@ +name: PHPUnit + +on: + push: + branches: [ "master", "main" ] + pull_request: + branches: [ "master", "main" ] + +permissions: + contents: read + +jobs: + test: + name: PHP ${{ matrix.php-version }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ["8.0", "8.1", "8.2", "8.3", "8.4"] + include: + - php-version: "8.3" + coverage: "true" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: pdo, pdo_sqlite + coverage: ${{ matrix.coverage == 'true' && 'xdebug' || 'none' }} + tools: composer:v2 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ~/.cache/composer + key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.json', '**/composer.lock') }} + restore-keys: | + composer-${{ runner.os }}-${{ matrix.php-version }}- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run tests + if: matrix.coverage != 'true' + run: vendor/bin/phpunit --no-coverage + + - name: Run tests with coverage + if: matrix.coverage == 'true' + run: vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml + + - name: Upload coverage artifact + if: matrix.coverage == 'true' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: build/coverage.xml + retention-days: 7 diff --git a/.gitignore b/.gitignore index 622e165..a49377b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ -/.idea/ -/.vs/ -/.vscode/ -/vendor/ -/composer.lock -/.phpunit.result.cache -/nbproject/private/ -/*.log \ No newline at end of file +/.idea/ +/.vs/ +/.vscode/ +/vendor/ +/build/ +/composer.lock +/.phpunit.result.cache +/.phpunit.cache/ +/nbproject/private/ +/*.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..30bc4c0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,79 @@ +# Changelog + +All notable changes to `initorm/dbal` will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] — 2.0.0 + +This release focuses on correctness, standards compliance, testability, and +documentation. Several long-standing bugs in DSN building and query +preparation have been fixed; these fixes are technically breaking for any +caller that depended on the previous (incorrect) behaviour. + +### Added +- PSR-4 `src/` layout. Autoload entries updated accordingly. +- Optional [PSR-3](https://www.php-fig.org/psr/psr-3/) `LoggerInterface` + support: any object implementing `Psr\Log\LoggerInterface` can be passed as + the `log` credential. PSR-3 `{placeholder}` format is honoured in + `createLog()`. +- `src/Connection/Support/DsnBuilder.php` — driver-aware DSN composer. +- `src/Connection/Support/QueryLogger.php` — encapsulates `addQueryLog` / + `getQueryLogs`. +- `src/Connection/Support/Logger.php` — encapsulates the `createLog` strategy + (PSR-3, callable, file path, duck-typed `critical`). +- `ConnectionAlreadyEstablishedException` (replaces the misnamed + `ValidConnectionAvailableException`, which is kept as a deprecated alias). +- Split interfaces (`ConfigurableConnectionInterface`, + `LoggableConnectionInterface`) to honour ISP; `ConnectionInterface` extends + both so existing consumers keep working. +- Full PHPUnit test suite (SQLite in-memory) with per-bug regression tests. +- GitHub Actions workflows: `phpunit.yml` (PHP 8.0–8.4 matrix) and + `composer-validate.yml`. +- `docs/` — full developer documentation with runnable examples. + +### Changed +- **BREAKING** PHP requirement raised from `>=7.4` to `>=8.0`. +- **BREAKING** Top-level `Connection/` and `DataMapper/` directories moved to + `src/Connection/` and `src/DataMapper/`. Consumers using Composer autoload + are unaffected; only direct path references (rare) break. +- **BREAKING** `PDO::ATTR_PERSISTENT` default is now `false`. Persistent + connections leak transactions and prepared-statement caches across + requests; opt in explicitly via `['options' => [PDO::ATTR_PERSISTENT => + true]]` if you need them. +- **BREAKING** `DataMapper::bind()` returns `PDO::PARAM_BOOL` for boolean + values (previously `PARAM_INT`). PostgreSQL boolean columns require this. +- **BREAKING** `DataMapper::rows()` returns `[]` (empty array) when no rows + match, instead of `null`. The return type is now `array` (non-nullable). +- `Connection::query()` now uses `$queryOptions + ($options ?? [])` instead of + `array_merge`, preserving the integer keys PDO requires for prepare + options. +- `Connection::connect()` only emits `SET NAMES / SET CHARACTER SET` for the + `mysql` driver, and rejects charset/collation values that contain non + `[A-Za-z0-9_]` characters (defence-in-depth against SQL injection through + configuration). +- The error message produced when a query fails no longer interpolates raw + parameter values into the SQL string via `strtr`. The parameter array is + serialised separately, avoiding `TypeError` when values contain `null`, + `bool`, or `int`. +- `createLog()` now formats messages with PSR-3 style `{key}` placeholders. + +### Fixed +- `Connection::getDsn()` no longer ignores its own auto-build branch + (`!isset` against an always-present key) and no longer writes the charset + into the `dbname=` part of the DSN. +- `Connection::connect()` reads the actual PDO driver name into the + credentials even when the user did not explicitly provide a driver. +- `Connection::query()` removes the dead `errorCode()` branch that could + never run under `ERRMODE_EXCEPTION`. +- `DataMapper::__get` / `__isset` PHPDoc no longer claims to throw an + exception that the implementation cannot raise. + +### Removed +- Unused `setDebug` / `getDebug` plumbing was wired into + `Connection::query()`: when enabled, error messages now include the SQL + and a JSON-encoded parameter dump. (Previously these methods set a flag + that nothing read.) +- `@version 1.0` tags from file-level docblocks — versioning is owned by + git tags. diff --git a/Connection/Connection.php b/Connection/Connection.php deleted file mode 100644 index 24ea921..0000000 --- a/Connection/Connection.php +++ /dev/null @@ -1,498 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\Connection; - -use InitORM\DBAL\Connection\Exceptions\SQLExecuteException; -use InitORM\DBAL\Connection\Interfaces\ConnectionInterface; -use InitORM\DBAL\Connection\Exceptions\ValidConnectionAvailableException; -use InitORM\DBAL\Connection\Exceptions\ConnectionException; -use InitORM\DBAL\DataMapper\DataMapperFactory; -use InitORM\DBAL\DataMapper\Interfaces\DataMapperFactoryInterface; -use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface; -use PDO; -use Throwable; - -use const PHP_EOL; - -class Connection implements ConnectionInterface -{ - - /** - * @var array - */ - protected array $credentials = [ - 'dsn' => '', - 'username' => null, - 'password' => null, - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'options' => [], - - 'driver' => 'mysql', - 'host' => '127.0.0.1', - 'port' => 3306, - 'database' => '', - - 'queryOptions' => [], - - 'log' => null, - 'debug' => false, - 'queryLogs' => false, - ]; - - /** - * @var PDO|null - */ - private ?PDO $pdo = null; - - /** - * @var array - */ - private array $queryLogs = []; - - /** - * @var DataMapperFactoryInterface - */ - private DataMapperFactoryInterface $dataMapperFactory; - - /** - * @inheritDoc - */ - public function __construct(array $credentials = []) - { - $this->credentials = array_merge($this->credentials, $credentials); - - $this->dataMapperFactory = new DataMapperFactory(); - } - - /** - * @param $name - * @param $arguments - * @return Connection|mixed - * @throws ConnectionException - */ - public function __call($name, $arguments) - { - $res = $this->getPDO()->{$name}(...$arguments); - - return ($res instanceof PDO) ? $this : $res; - } - - /** - * @inheritDoc - */ - public function clone(): self - { - return new self($this->credentials); - } - - /** - * @inheritDoc - */ - public function getPDO(): PDO - { - !isset($this->pdo) && $this->connect(); - - return $this->pdo; - } - - /** - * @inheritDoc - */ - public function connect(): bool - { - try { - $this->pdo = new PDO($this->getDsn(), $this->getUsername(), $this->getPassword(), $this->getOptions()); - - if ($charset = $this->getCharset()) { - if ($collation = $this->getCollation()) { - $this->pdo->exec("SET NAMES '" . $charset . "' COLLATE '" . $collation . "'"); - } - $this->pdo->exec("SET CHARACTER SET '" . $charset . "'"); - } - - if (!isset($this->credentials['driver'])) { - $this->credentials['driver'] = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - } - - return true; - } catch (Throwable $e) { - throw new ConnectionException($e->getMessage(), (int)$e->getCode(), $e->getPrevious()); - } - } - - /** - * @inheritDoc - */ - public function disconnect(): bool - { - $this->pdo = null; - - return true; - } - - /** - * @inheritDoc - */ - public function setDatabase(string $database): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - $this->credentials['database'] = $database; - - return $this; - } - - /** - * @inheritDoc - */ - public function getDatabase(): ?string - { - return $this->credentials['database'] ?? null; - } - - /** - * @inheritDoc - */ - public function setHost(string $host): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - $this->credentials['host'] = $host; - - return $this; - } - - /** - * @inheritDoc - */ - public function getHost(): ?string - { - return $this->credentials['host'] ?? null; - } - - /** - * @inheritDoc - */ - public function setPort($port): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - $this->credentials['port'] = $port; - - return $this; - } - - /** - * @inheritDoc - */ - public function getPort() - { - return $this->credentials['port'] ?? null; - } - - /** - * @inheritDoc - */ - public function setCharset(string $charset = 'utf8mb4', ?string $collation = null): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - $this->credentials['charset'] = $charset; - $this->credentials['collation'] = empty($collation) ? $charset . '_unicode_ci' : $collation; - - return $this; - } - - /** - * @inheritDoc - */ - public function getCharset(): string - { - return $this->credentials['charset']; - } - - /** - * @inheritDoc - */ - public function getCollation(): string - { - return $this->credentials['collation']; - } - - /** - * @inheritDoc - */ - public function setDsn(string $dsn): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - $this->credentials['dsn'] = $dsn; - - return $this; - } - - /** - * @inheritDoc - */ - public function getDsn(): string - { - if (!isset($this->credentials['dsn'])) { - $dsn = $this->credentials['driver'] . ':host=' . $this->credentials['host'] - . ';port=' . $this->credentials['port'] . ';dbname=' . $this->credentials['charset'] - . ';charset=' . $this->credentials['charset']; - - $this->credentials['dsn'] = $dsn; - } - - return $this->credentials['dsn']; - } - - /** - * @inheritDoc - */ - public function setUsername(?string $username): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - $this->credentials['username'] = $username; - - return $this; - } - - /** - * @inheritDoc - */ - public function getUsername(): ?string - { - return $this->credentials['username'] ?? null; - } - - /** - * @inheritDoc - */ - public function setPassword(?string $password = null): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - $this->credentials['password'] = $password; - - return $this; - } - - /** - * @inheritDoc - */ - public function getPassword(): ?string - { - return $this->credentials['password'] ?? null; - } - - /** - * @inheritDoc - */ - public function setDriver(string $driver): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - $this->credentials['driver'] = $driver; - - return $this; - } - - /** - * @inheritDoc - */ - public function getDriver(): string - { - return $this->credentials['driver']; - } - - /** - * @inheritDoc - */ - public function setOptions(array $options = []): self - { - if (isset($this->pdo)) { - throw new ValidConnectionAvailableException(); - } - - !empty($options) && $this->credentials['options'] = array_merge($this->credentials['options'], $options); - - return $this; - } - - /** - * @inheritDoc - */ - public function getOptions(): array - { - return array_merge([ - PDO::ATTR_EMULATE_PREPARES => false, - PDO::ATTR_PERSISTENT => true, - PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_CLASS, - ], $this->credentials['options']); - } - - /** - * @inheritDoc - */ - public function addQueryLog(string $query, ?array $args = null, $startTime = null): void - { - if (!$this->credentials['queryLogs']) { - return; - } - $this->queryLogs[] = [ - 'query' => $query, - 'args' => $args, - 'timer' => round(microtime(true) - $startTime, 6), - ]; - } - - /** - * @inheritDoc - */ - public function getQueryLogs(): array - { - return $this->queryLogs; - } - - /** - * @inheritDoc - */ - public function setQueryLogs(bool $status = false): self - { - $this->credentials['queryLogs'] = $status; - - return $this; - } - - /** - * @inheritDoc - */ - public function createLog(string $message, ?array $context = null): bool - { - if (empty($this->credentials['log'])) { - return false; - } - - !empty($context) && $message = strtr($message, $context); - - if (is_callable($this->credentials['log'])) { - call_user_func_array($this->credentials['log'], [$message]); - - return true; - } - - if (is_string($this->credentials['log'])) { - $path = strtr($this->credentials['log'], [ - '{timestamp}' => time(), - '{date}' => date("Y-m-d"), - '{datetime}' => date("Y-m-d-H-i-s"), - '{year}' => date("Y"), - '{month}' => date("m"), - '{day}' => date("d"), - '{hour}' => date("H"), - '{minute}' => date("i"), - '{second}' => date("s"), - ]); - - return (bool)@file_put_contents($path, $message, FILE_APPEND); - } - - if (is_object($this->credentials['log']) && method_exists($this->credentials['log'], 'critical')) { - call_user_func_array([$this->credentials['log'], 'critical'], [$message]); - - return true; - } - - return false; - } - - /** - * @inheritDoc - */ - public function setDebug(bool $status = false): self - { - $this->credentials['debug'] = $status; - - return $this; - } - - /** - * @inheritDoc - */ - public function getDebug(): bool - { - return $this->credentials['debug']; - } - - /** - * @inheritDoc - * @throws Throwable - */ - public function query(string $sqlQuery, ?array $parameters = null, ?array $options = null): DataMapperInterface - { - $startTime = microtime(true); - try { - $stmt = $this->getPDO()->prepare($sqlQuery, array_merge($this->credentials['queryOptions'], $options ?? [])); - if (!$stmt) { - throw new SQLExecuteException('The SQL query could not be prepared.'); - } - $dataMapper = $this->dataMapperFactory->createDataMapper($stmt); - - !empty($parameters) && $dataMapper->bindValues($parameters); - - if (!$dataMapper->execute()) { - throw new SQLExecuteException('The SQL query could not be executed.'); - } - $this->addQueryLog($sqlQuery, $parameters, $startTime); - - $errorCode = $stmt->errorCode(); - if ($errorCode !== null && !empty(trim($errorCode, "0 \n\r\t\v\0"))) { - $errorInfo = $stmt->errorInfo(); - if (isset($errorInfo[2])) { - $message = $errorCode . ' - ' . $errorInfo[2]; - throw new SQLExecuteException($message); - } - } - - return $dataMapper; - } catch (Throwable $e) { - $this->addQueryLog($sqlQuery, $parameters, $startTime); - $message = $e->getMessage() . PHP_EOL . 'SQL : "' . strtr($sqlQuery, $parameters ?? []) . '"'; - $this->createLog($message); - throw $e; - } - } - -} diff --git a/Connection/ConnectionFactory.php b/Connection/ConnectionFactory.php deleted file mode 100644 index 8bbc11d..0000000 --- a/Connection/ConnectionFactory.php +++ /dev/null @@ -1,31 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\Connection; - -use InitORM\DBAL\Connection\Interfaces\ConnectionFactoryInterface; -use InitORM\DBAL\Connection\Interfaces\ConnectionInterface; - -class ConnectionFactory implements ConnectionFactoryInterface -{ - - /** - * @inheritDoc - */ - public function createConnection(array $credentials = []): ConnectionInterface - { - return new Connection($credentials); - } - -} diff --git a/Connection/Exceptions/ConnectionException.php b/Connection/Exceptions/ConnectionException.php deleted file mode 100644 index 101332d..0000000 --- a/Connection/Exceptions/ConnectionException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\Connection\Exceptions; - -use Exception; - -class ConnectionException extends Exception -{ -} diff --git a/Connection/Exceptions/ConnectionInvalidArgumentException.php b/Connection/Exceptions/ConnectionInvalidArgumentException.php deleted file mode 100644 index 835ac94..0000000 --- a/Connection/Exceptions/ConnectionInvalidArgumentException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\Connection\Exceptions; - -use InvalidArgumentException; - -class ConnectionInvalidArgumentException extends InvalidArgumentException -{ -} diff --git a/Connection/Exceptions/SQLExecuteException.php b/Connection/Exceptions/SQLExecuteException.php deleted file mode 100644 index 9b1eb81..0000000 --- a/Connection/Exceptions/SQLExecuteException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\Connection\Exceptions; - -class SQLExecuteException extends ConnectionException -{ -} diff --git a/Connection/Exceptions/ValidConnectionAvailableException.php b/Connection/Exceptions/ValidConnectionAvailableException.php deleted file mode 100644 index 184f768..0000000 --- a/Connection/Exceptions/ValidConnectionAvailableException.php +++ /dev/null @@ -1,19 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\Connection\Exceptions; - -class ValidConnectionAvailableException extends ConnectionException -{ -} diff --git a/Connection/Interfaces/ConnectionFactoryInterface.php b/Connection/Interfaces/ConnectionFactoryInterface.php deleted file mode 100644 index 561da46..0000000 --- a/Connection/Interfaces/ConnectionFactoryInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\Connection\Interfaces; - -interface ConnectionFactoryInterface -{ - - /** - * @param array $credentials - * @return ConnectionInterface - */ - public function createConnection(array $credentials = []): ConnectionInterface; - -} diff --git a/Connection/Interfaces/ConnectionInterface.php b/Connection/Interfaces/ConnectionInterface.php deleted file mode 100644 index 1a59e40..0000000 --- a/Connection/Interfaces/ConnectionInterface.php +++ /dev/null @@ -1,210 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\Connection\Interfaces; - -use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface; -use PDO; -use InitORM\DBAL\Connection\Exceptions\ConnectionException; -use InitORM\DBAL\Connection\Exceptions\ValidConnectionAvailableException; - -interface ConnectionInterface -{ - - /** - * @param array $credentials - */ - public function __construct(array $credentials = []); - - /** - * @return self - */ - public function clone(): self; - - /** - * @return PDO - * @throws ConnectionException - */ - public function getPDO(): PDO; - - /** - * @param string $sqlQuery - * @param array|null $parameters - * @param array|null $options - * @return DataMapperInterface - */ - public function query(string $sqlQuery, ?array $parameters = null, ?array $options = null): DataMapperInterface; - - /** - * @return bool - * @throws ConnectionException - */ - public function connect(): bool; - - /** - * @return bool - */ - public function disconnect(): bool; - - /** - * @param string $database - * @return self - * @throws ValidConnectionAvailableException - */ - public function setDatabase(string $database): self; - - /** - * @return string|null - */ - public function getDatabase(): ?string; - - /** - * @param string $host - * @return self - * @throws ValidConnectionAvailableException - */ - public function setHost(string $host): self; - - /** - * @return string|null - */ - public function getHost(): ?string; - - /** - * @param int|string $port - * @return self - * @throws ValidConnectionAvailableException - */ - public function setPort($port): self; - - /** - * @return string|int|null - */ - public function getPort(); - - /** - * @param string $charset - * @param string|null $collation

IS NULL => {$charset}_unicode_ci

- * @return self - * @throws ValidConnectionAvailableException - */ - public function setCharset(string $charset = 'utf8mb4', ?string $collation = null): self; - - /** - * @return string - */ - public function getCharset(): string; - - /** - * @return string - */ - public function getCollation(): string; - - /** - * @param string $dsn - * @return self - * @throws ValidConnectionAvailableException - */ - public function setDsn(string $dsn): self; - - /** - * @return string - */ - public function getDsn(): string; - - /** - * @param string|null $username - * @return self - * @throws ValidConnectionAvailableException - */ - public function setUsername(?string $username): self; - - /** - * @return string|null - */ - public function getUsername(): ?string; - - /** - * @param null|string $password - * @return self - * @throws ValidConnectionAvailableException - */ - public function setPassword(?string $password = null): self; - - /** - * @return string|null - */ - public function getPassword(): ?string; - - /** - * @param string $driver - * @return self - * @throws ValidConnectionAvailableException - */ - public function setDriver(string $driver): self; - - /** - * @return string - */ - public function getDriver(): string; - - /** - * @param array $options - * @return self - * @throws ValidConnectionAvailableException - */ - public function setOptions(array $options = []): self; - - /** - * @return array - */ - public function getOptions(): array; - - /** - * @param string $query - * @param array|null $args - * @param float|string $startTime

microtime(true)

- * @return void - */ - public function addQueryLog(string $query, ?array $args = null, $startTime = null): void; - - /** - * @return array - */ - public function getQueryLogs(): array; - - /** - * @param bool $status - * @return self - */ - public function setQueryLogs(bool $status = false): self; - - /** - * @param string $message - * @param array|null $context - * @return bool - */ - public function createLog(string $message, ?array $context = null): bool; - - /** - * @param bool $status - * @return self - */ - public function setDebug(bool $status = false): self; - - /** - * @return bool - */ - public function getDebug(): bool; -} diff --git a/DataMapper/DataMapper.php b/DataMapper/DataMapper.php deleted file mode 100644 index 8f2c5b1..0000000 --- a/DataMapper/DataMapper.php +++ /dev/null @@ -1,220 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\DataMapper; - -use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface; -use PDO; -use PDOStatement; -use InitORM\DBAL\DataMapper\Exceptions\DataMapperException; - -class DataMapper implements DataMapperInterface -{ - - private PDOStatement $statement; - - public function __construct(PDOStatement $statement) - { - $this->statement = $statement; - } - - /** - * @throws DataMapperException - */ - public function __call($name, $arguments) - { - $res = $this->getStatement()->{$name}(...$arguments); - - return ($res instanceof PDOStatement) ? $this : $res; - } - - /** - * @param $name - * @return mixed - * @throws DataMapperException - */ - public function __get($name) - { - return $this->getStatement()->{$name}; - } - - /** - * @param $name - * @return bool - * @throws DataMapperException - */ - public function __isset($name) - { - return isset($this->getStatement()->{$name}); - } - - /** - * @inheritDoc - */ - public function getStatement(): PDOStatement - { - if (!isset($this->statement)) { - throw new DataMapperException(); - } - - return $this->statement; - } - - /** - * @inheritDoc - */ - public function execute(?array $params = null): bool - { - return $this->getStatement()->execute($params); - } - - /** - * @inheritDoc - */ - public function getQuery(): string - { - return $this->getStatement()->queryString; - } - - /** - * @inheritDoc - */ - public function bindValue(string $key, $value): bool - { - $key = ':' . ltrim($key, ':'); - - return $this->getStatement()->bindValue($key, $value, $this->bind($value)); - } - - /** - * @inheritDoc - */ - public function bindValues(array $fields): bool - { - foreach ($fields as $key => $value) { - $this->bindValue($key, $value); - } - - return true; - } - - /** - * @inheritDoc - */ - public function bind($value): int - { - switch (true) { - case is_int($value): - case is_bool($value): - return PDO::PARAM_INT; - case is_null($value): - return PDO::PARAM_NULL; - default: - return PDO::PARAM_STR; - } - } - - /** - * @inheritDoc - */ - public function numRows(): int - { - $count = $this->getStatement()->rowCount(); - - return empty($count) ? 0 : $count; - } - - /** - * @inheritDoc - */ - public function asClass(?string $class = null): self - { - $this->getStatement()->setFetchMode(PDO::FETCH_CLASS, $class); - - return $this; - } - - /** - * @inheritDoc - */ - public function asObject(?object $obj = null): self - { - if (null !== $obj) { - $this->getStatement()->setFetchMode(PDO::FETCH_INTO, $obj); - } else { - $this->getStatement()->setFetchMode(PDO::FETCH_OBJ); - } - - return $this; - } - - /** - * @inheritDoc - */ - public function asAssoc(): self - { - $this->getStatement()->setFetchMode(PDO::FETCH_ASSOC); - - return $this; - } - - /** - * @inheritDoc - */ - public function asLazy(): self - { - $this->getStatement()->setFetchMode(PDO::FETCH_LAZY); - - return $this; - } - - /** - * @inheritDoc - */ - public function asArray(): self - { - $this->getStatement()->setFetchMode(PDO::FETCH_BOTH); - return $this; - } - - /** - * @inheritDoc - */ - public function asBoth(): self - { - return $this->asArray(); - } - - /** - * @inheritDoc - */ - public function row() - { - $res = $this->getStatement()->fetch(); - - return !empty($res) ? $res : null; - } - - /** - * @inheritDoc - */ - public function rows(): ?array - { - $res = $this->getStatement()->fetchAll(); - - return !empty($res) ? $res : null; - } - - -} diff --git a/DataMapper/DataMapperFactory.php b/DataMapper/DataMapperFactory.php deleted file mode 100644 index 244c0e1..0000000 --- a/DataMapper/DataMapperFactory.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\DataMapper; - -use InitORM\DBAL\DataMapper\Interfaces\DataMapperFactoryInterface; -use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface; -use PDOStatement; - -class DataMapperFactory implements Interfaces\DataMapperFactoryInterface -{ - - public function createDataMapper(PDOStatement $statement): DataMapperInterface - { - return new DataMapper($statement); - } - -} diff --git a/DataMapper/Exceptions/DataMapperException.php b/DataMapper/Exceptions/DataMapperException.php deleted file mode 100644 index f4685c8..0000000 --- a/DataMapper/Exceptions/DataMapperException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\DataMapper\Exceptions; - -use Exception; - -class DataMapperException extends Exception -{ -} diff --git a/DataMapper/Exceptions/DataMapperInvalidArgumentException.php b/DataMapper/Exceptions/DataMapperInvalidArgumentException.php deleted file mode 100644 index 98b3528..0000000 --- a/DataMapper/Exceptions/DataMapperInvalidArgumentException.php +++ /dev/null @@ -1,21 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\DataMapper\Exceptions; - -use InvalidArgumentException; - -class DataMapperInvalidArgumentException extends InvalidArgumentException -{ -} diff --git a/DataMapper/Interfaces/DataMapperFactoryInterface.php b/DataMapper/Interfaces/DataMapperFactoryInterface.php deleted file mode 100644 index b518762..0000000 --- a/DataMapper/Interfaces/DataMapperFactoryInterface.php +++ /dev/null @@ -1,24 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\DataMapper\Interfaces; - -use PDOStatement; - -interface DataMapperFactoryInterface -{ - - public function createDataMapper(PDOStatement $statement): DataMapperInterface; - -} diff --git a/DataMapper/Interfaces/DataMapperInterface.php b/DataMapper/Interfaces/DataMapperInterface.php deleted file mode 100644 index 1571c88..0000000 --- a/DataMapper/Interfaces/DataMapperInterface.php +++ /dev/null @@ -1,128 +0,0 @@ - - * @copyright Copyright © 2023 Muhammet ŞAFAK - * @license ./LICENSE MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr - */ - -declare(strict_types=1); -namespace InitORM\DBAL\DataMapper\Interfaces; - -use InitORM\DBAL\DataMapper\Exceptions\DataMapperException; -use PDOStatement; - -/** - * @mixin PDOStatement - */ -interface DataMapperInterface -{ - - public function __construct(PDOStatement $statement); - - /** - * @return PDOStatement - * @throws DataMapperException - */ - public function getStatement(): PDOStatement; - - /** - * @param array|null $params - * @return bool - * @throws DataMapperException - */ - public function execute(?array $params = null): bool; - - - /** - * @return string - * @throws DataMapperException - */ - public function getQuery(): string; - - /** - * @param string $key - * @param mixed $value - * @return bool - * @throws DataMapperException - */ - public function bindValue(string $key, $value): bool; - - /** - * @param array $fields - * @return bool - * @throws DataMapperException - */ - public function bindValues(array $fields): bool; - - /** - * @param mixed $value - * @return int - * @throws DataMapperException - */ - public function bind($value): int; - - /** - * @return int - * @throws DataMapperException - * @see PDOStatement::rowCount() - */ - public function numRows(): int; - - /** - * @param string|null $class - * @return self - * @throws DataMapperException - */ - public function asClass(?string $class = null): self; - - /** - * @param null|object $obj - * @return self - * @throws DataMapperException - */ - public function asObject(?object $obj = null): self; - - /** - * @return self - * @throws DataMapperException - */ - public function asAssoc(): self; - - /** - * @return self - * @throws DataMapperException - */ - public function asLazy(): self; - - /** - * @return self - * @throws DataMapperException - */ - public function asArray(): self; - - /** - * @return self - * @throws DataMapperException - * @see self::asArray() - */ - public function asBoth(): self; - - /** - * @return array|object|null - * @throws DataMapperException - */ - public function row(); - - /** - * @return array|object[]|null - * @throws DataMapperException - */ - public function rows(): ?array; - -} diff --git a/README.md b/README.md index a27a5b1..e4166cf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,113 @@ -# InitORM DBAL - -``` -composer require initorm/dbal -``` \ No newline at end of file +# InitORM DBAL + +[![PHPUnit](https://github.com/InitORM/DBAL/actions/workflows/phpunit.yml/badge.svg)](https://github.com/InitORM/DBAL/actions/workflows/phpunit.yml) +[![Latest Stable Version](https://poser.pugx.org/initorm/dbal/v/stable)](https://packagist.org/packages/initorm/dbal) +[![Total Downloads](https://poser.pugx.org/initorm/dbal/downloads)](https://packagist.org/packages/initorm/dbal) +[![License](https://poser.pugx.org/initorm/dbal/license)](LICENSE) +[![PHP Version Require](https://poser.pugx.org/initorm/dbal/require/php)](https://packagist.org/packages/initorm/dbal) + +A small, dependency-free database abstraction layer for PHP. `initorm/dbal` +gives you a thin, lazily-connecting PDO wrapper and a fluent result mapper — +nothing more. + +It is part of the [InitORM](https://github.com/InitORM) stack, but it has no +runtime dependencies and can be used on its own anywhere PDO can. + +## Highlights + +- **Lazy connections.** No socket is opened until the first query. +- **Driver-aware DSN building.** MySQL/MariaDB, PostgreSQL, SQLite. +- **Fluent result mapper.** `asAssoc()`, `asObject()`, `asClass()`, + `asLazy()`, `asArray()` / `asBoth()`. +- **Type-aware binding.** `bool` → `PARAM_BOOL`, `int` → `PARAM_INT`, + `null` → `PARAM_NULL`, everything else → `PARAM_STR`. +- **In-memory query log** for debugging hotspots. +- **PSR-3 friendly logging.** Pass any `LoggerInterface`, a callable, a file + path, or anything with a `critical()` method. +- **Transparent PDO forwarding.** Unknown method calls are forwarded to the + underlying `PDO` / `PDOStatement`, so `lastInsertId()`, `beginTransaction()`, + `closeCursor()`, etc. all work directly on the wrapper. + +## Installation + +```bash +composer require initorm/dbal +``` + +Requirements: **PHP 8.0+** and the `pdo` extension, plus the driver +extension for the database you target (`pdo_mysql`, `pdo_pgsql`, +`pdo_sqlite`). + +## 60-second quick start + +```php +use InitORM\DBAL\Connection\Connection; + +$db = new Connection([ + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'port' => 3306, + 'database' => 'shop', + 'username' => 'app', + 'password' => 'secret', + 'charset' => 'utf8mb4', +]); + +// Read +$user = $db->query('SELECT id, name FROM users WHERE id = :id', ['id' => 42]) + ->asAssoc() + ->row(); + +// Write +$db->query( + 'INSERT INTO users (name, email) VALUES (:name, :email)', + ['name' => 'Alice', 'email' => 'alice@example.com'] +); +$newId = (int) $db->lastInsertId(); // forwarded to PDO +``` + +## Documentation + +Full docs live in [`docs/`](docs/): + +- [01 · Getting Started](docs/01-getting-started.md) +- [02 · Connection](docs/02-connection.md) +- [03 · Querying](docs/03-querying.md) +- [04 · DataMapper](docs/04-data-mapper.md) +- [05 · Transactions](docs/05-transactions.md) +- [06 · Logging](docs/06-logging.md) +- [07 · Factories & DI](docs/07-factories-and-di.md) +- [08 · Exceptions](docs/08-exceptions.md) +- [09 · Recipes](docs/09-recipes.md) + +## Testing + +```bash +composer install +composer test +``` + +The suite runs against SQLite in-memory and finishes in well under a +second. + +## Upgrading from 1.x + +See [`UPGRADE.md`](UPGRADE.md). The notable breaking changes are: PHP ≥ 8.0, +`src/` layout, `PDO::ATTR_PERSISTENT` defaults to `false`, `rows()` returns +`[]` instead of `null` when empty, and `bind()` returns `PARAM_BOOL` for +booleans. + +## Contributing + +Issues and pull requests are welcome. Please: + +1. Open an issue first if you intend to make a non-trivial change. +2. Make sure `composer test` is green. +3. Cover new behaviour with a test under `tests/`. + +See the organisation-wide [contribution guide](https://github.com/InitORM/.github/blob/master/CONTRIBUTING.md) +for the full process. + +## License + +[MIT](LICENSE) © Muhammet ŞAFAK diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..8244dcf --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,113 @@ +# Upgrade Guide + +## From 1.x to 2.0 + +### Minimum PHP version + +`initorm/dbal` now requires **PHP 8.0 or later**. If you are still on 7.4, +stay on the `1.x` branch. + +```diff +- "require": { "initorm/dbal": "^1.0" } ++ "require": { "initorm/dbal": "^2.0" } +``` + +### Directory layout + +Sources moved from the repository root to `src/`: + +``` +Connection/ → src/Connection/ +DataMapper/ → src/DataMapper/ +``` + +If you depend on the package through Composer's autoloader, nothing changes. +If your build scripts hard-coded the old paths (rare), update them. + +### Persistent connections are now opt-in + +`PDO::ATTR_PERSISTENT` defaults to `false`. In 1.x it was `true`, which +silently shared connections — and therefore transactions and prepared +statement caches — across requests under most SAPIs. + +To restore the old behaviour: + +```php +new Connection([ + // ... + 'options' => [ + \PDO::ATTR_PERSISTENT => true, + ], +]); +``` + +### `DataMapper::rows()` returns `[]` instead of `null` when empty + +```diff +- $rows = $mapper->rows(); +- if ($rows === null) { ... } ++ $rows = $mapper->rows(); ++ if ($rows === []) { ... } +``` + +The return type is now `array` (non-nullable). + +### `DataMapper::bind()` uses `PDO::PARAM_BOOL` for booleans + +Previously booleans were bound as `PARAM_INT`. PostgreSQL rejects integer +literals where a `boolean` is expected, so this was a latent bug. MySQL is +unaffected in practice. If you relied on the integer coercion, cast +explicitly: + +```diff +- $mapper->bindValue('active', $isActive); // was bound as INT(0|1) ++ $mapper->bindValue('active', (int) $isActive); // explicit cast +``` + +### `ValidConnectionAvailableException` renamed + +`InitORM\DBAL\Connection\Exceptions\ConnectionAlreadyEstablishedException` is +the new name. The old class remains as a deprecated alias that extends the +new one, so existing `catch` blocks keep working — but you should rename +references at your earliest convenience. + +### DSN auto-build now works + +In 1.x, `Connection::getDsn()` had two bugs that made the auto-build branch +unreachable, and (when reached) put the charset value into `dbname=`. If you +were quietly passing a full `dsn` credential to work around this, you no +longer need to: + +```php +new Connection([ + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'port' => 3306, + 'database' => 'app', + 'charset' => 'utf8mb4', + // 'dsn' is now built correctly from the above +]); +``` + +### `createLog()` placeholders are PSR-3 style + +Context keys must now be wrapped in braces in the message template: + +```diff +- $connection->createLog('user logged in: id', ['id' => 42]); ++ $connection->createLog('user logged in: {id}', ['id' => 42]); +``` + +### PSR-3 logger support + +You can now pass any `Psr\Log\LoggerInterface` as the `log` credential: + +```php +new Connection([ + // ... + 'log' => $psr3Logger, +]); +``` + +Existing callable / file-path / `critical()` duck-typed loggers continue to +work. diff --git a/composer.json b/composer.json index c5d6cd5..4ec39dc 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,24 @@ { "name": "initorm/dbal", - "description": "InitORM Database Abstract Layer", + "description": "Lightweight PDO-based database abstraction layer with a fluent result mapper.", "type": "library", "license": "MIT", - "autoload": { - "psr-4": { - "InitORM\\DBAL\\Connection\\": "Connection/", - "InitORM\\DBAL\\DataMapper\\": "DataMapper/" - } + "keywords": [ + "dbal", + "database", + "pdo", + "abstraction-layer", + "data-mapper", + "mysql", + "pgsql", + "sqlite", + "initorm" + ], + "homepage": "https://github.com/InitORM/DBAL", + "support": { + "issues": "https://github.com/InitORM/DBAL/issues", + "source": "https://github.com/InitORM/DBAL", + "docs": "https://github.com/InitORM/DBAL/tree/master/docs" }, "authors": [ { @@ -17,9 +28,38 @@ "homepage": "https://www.muhammetsafak.com.tr" } ], - "minimum-stability": "stable", "require": { - "php": ">=7.4", + "php": ">=8.0", "ext-pdo": "*" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "ext-pdo_sqlite": "*" + }, + "suggest": { + "psr/log": "Allows passing any PSR-3 LoggerInterface as the 'log' credential.", + "ext-pdo_mysql": "Required for MySQL/MariaDB connections.", + "ext-pdo_pgsql": "Required for PostgreSQL connections.", + "ext-pdo_sqlite": "Required for SQLite connections." + }, + "autoload": { + "psr-4": { + "InitORM\\DBAL\\Connection\\": "src/Connection/", + "InitORM\\DBAL\\DataMapper\\": "src/DataMapper/" + } + }, + "autoload-dev": { + "psr-4": { + "InitORM\\DBAL\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-text --coverage-html=build/coverage" + }, + "minimum-stability": "stable", + "config": { + "sort-packages": true } } diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md new file mode 100644 index 0000000..8c587e2 --- /dev/null +++ b/docs/01-getting-started.md @@ -0,0 +1,66 @@ +# 01 · Getting Started + +`initorm/dbal` is a thin layer over PDO. It does **not** parse SQL, build +queries, or model tables — that is the job of higher layers in the InitORM +stack (`initorm/query-builder`, `initorm/database`, `initorm/orm`). What it +gives you is: + +1. A `Connection` that lazily instantiates PDO from a credentials array. +2. A `DataMapper` that wraps each `PDOStatement` and exposes a small, + fluent API for binding values and fetching results. + +This page walks through the smallest useful program end-to-end. Every +snippet runs unmodified against SQLite in-memory. + +## Install + +```bash +composer require initorm/dbal +``` + +Requirements: PHP 8.0+, `ext-pdo`, and the driver extension for your +database (`pdo_mysql`, `pdo_pgsql`, or `pdo_sqlite`). + +## Open a connection + +```php +use InitORM\DBAL\Connection\Connection; + +$db = new Connection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'charset' => '', // sqlite has no charset concept +]); +``` + +No connection is opened yet — `Connection` only instantiates PDO when you +call `getPDO()`, `query()`, or any forwarded PDO method. + +## Run a query + +```php +$db->getPDO()->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + +$db->query( + 'INSERT INTO users (name) VALUES (:name)', + ['name' => 'Alice'] +); + +$user = $db->query('SELECT * FROM users WHERE id = :id', ['id' => 1]) + ->asAssoc() + ->row(); + +// ['id' => 1, 'name' => 'Alice'] +``` + +Three things happen on the read: + +1. `query()` prepares the statement and binds `:id` with `PARAM_INT`. +2. It returns a `DataMapper`. +3. `asAssoc()` sets the fetch mode; `row()` returns the next row (or `null`). + +## What's next + +- [02 · Connection](02-connection.md) — credentials, lifecycle, cloning. +- [03 · Querying](03-querying.md) — parameters, prepare options, errors. +- [04 · DataMapper](04-data-mapper.md) — fetch modes, binding, forwarding. diff --git a/docs/02-connection.md b/docs/02-connection.md new file mode 100644 index 0000000..60d6bf5 --- /dev/null +++ b/docs/02-connection.md @@ -0,0 +1,133 @@ +# 02 · Connection + +`InitORM\DBAL\Connection\Connection` is the single entry point. This page +covers credentials, lifecycle, and cloning. + +## Credentials + +The constructor accepts an associative array merged on top of these +defaults: + +| Key | Default | Notes | +|----------------|--------------------|--------------------------------------------------------| +| `dsn` | `''` | If empty, auto-built from `driver`/`host`/etc. | +| `driver` | `'mysql'` | `mysql`, `pgsql`, `sqlite`, or anything PDO supports. | +| `host` | `'127.0.0.1'` | | +| `port` | `3306` | Use `5432` for PostgreSQL. | +| `database` | `''` | | +| `username` | `null` | | +| `password` | `null` | | +| `charset` | `'utf8mb4'` | MySQL-only `SET NAMES`. Pass `''` to skip. | +| `collation` | `null` | When set, appended as `COLLATE` to `SET NAMES` (MySQL).| +| `options` | `[]` | PDO attribute map; merged with library defaults. | +| `queryOptions` | `[]` | Default PDO prepare options applied to every query. | +| `log` | `null` | See [06 · Logging](06-logging.md). | +| `debug` | `false` | When true, query failure logs include serialised args. | +| `queryLogs` | `false` | When true, every query is appended to the log buffer. | + +### MySQL + +```php +$db = new Connection([ + 'driver' => 'mysql', + 'host' => 'db', + 'port' => 3306, + 'database' => 'shop', + 'username' => 'app', + 'password' => getenv('DB_PASS'), + 'charset' => 'utf8mb4', + 'collation'=> 'utf8mb4_unicode_ci', +]); +``` + +### PostgreSQL + +```php +$db = new Connection([ + 'driver' => 'pgsql', + 'host' => 'pg', + 'port' => 5432, + 'database' => 'shop', + 'username' => 'app', + 'password' => getenv('DB_PASS'), + 'charset' => '', // pgsql does not use SET NAMES +]); +``` + +### SQLite + +```php +$db = new Connection([ + 'driver' => 'sqlite', + 'database' => __DIR__ . '/app.sqlite', // or ':memory:' + 'charset' => '', +]); +``` + +## Lifecycle + +```php +$db->getPDO(); // opens the connection on first call +$db->disconnect();// releases the in-process PDO reference +``` + +A few rules: + +- **Once a PDO instance exists, credential setters throw.** Calling + `setHost()` / `setDatabase()` / etc. after `getPDO()` raises + `ConnectionAlreadyEstablishedException` (the historical + `ValidConnectionAvailableException` is its parent, so old `catch` blocks + still match). +- **`disconnect()` does not close persistent connections.** PHP returns + them to the pool — see the note on `PDO::ATTR_PERSISTENT` below. + +```php +use InitORM\DBAL\Connection\Exceptions\ConnectionAlreadyEstablishedException; + +$db->getPDO(); +try { + $db->setDatabase('other'); +} catch (ConnectionAlreadyEstablishedException $e) { + $db->disconnect(); + $db->setDatabase('other'); + $db->getPDO(); +} +``` + +## Default PDO options + +| Attribute | Default value | +|------------------------------|---------------------------| +| `ATTR_EMULATE_PREPARES` | `false` | +| `ATTR_PERSISTENT` | `false` *(was true in 1.x)*| +| `ATTR_ERRMODE` | `ERRMODE_EXCEPTION` | +| `ATTR_DEFAULT_FETCH_MODE` | `FETCH_ASSOC` | + +Any entry you put in the `options` credential overrides the default. +`setOptions()` is also available at runtime — until the connection has been +opened. + +```php +$db = new Connection([ + 'driver' => 'mysql', + 'options' => [ + PDO::ATTR_PERSISTENT => true, // opt in to persistence + PDO::ATTR_TIMEOUT => 5, + ], +]); +``` + +## Cloning + +`clone()` returns a fresh, disconnected Connection that carries the same +credentials. Use it when you need a sibling connection (a second cursor, +parallel transaction, separate database) without re-wiring the configuration. + +```php +$readReplica = $primary->clone()->setHost('replica'); +``` + +## What's next + +- [03 · Querying](03-querying.md) for the `query()` method itself. +- [05 · Transactions](05-transactions.md) for `beginTransaction`/`commit`. diff --git a/docs/03-querying.md b/docs/03-querying.md new file mode 100644 index 0000000..a962537 --- /dev/null +++ b/docs/03-querying.md @@ -0,0 +1,110 @@ +# 03 · Querying + +## The `query()` method + +```php +public function query( + string $sqlQuery, + ?array $parameters = null, + ?array $options = null +): DataMapperInterface; +``` + +Three things happen on every call: + +1. `PDO::prepare()` is called with `$sqlQuery` and any prepare `$options`. +2. If `$parameters` is non-empty, each entry is bound via + `DataMapper::bindValue()` with a type chosen by `bind()`. +3. `execute()` runs; the resulting `DataMapper` is returned for fetching. + +If preparation or execution fails, the original PDO/Throwable exception is +re-thrown after the failure is recorded in the query log (when enabled) and +forwarded to the configured logger (when configured). + +## Named parameters + +Keys may include or omit the leading `:`: + +```php +$db->query('SELECT * FROM users WHERE id = :id', ['id' => 1]); // works +$db->query('SELECT * FROM users WHERE id = :id', [':id' => 1]); // also works +``` + +Type selection is automatic: + +| PHP type | PDO bind type | +|----------|-----------------| +| `bool` | `PARAM_BOOL` | +| `int` | `PARAM_INT` | +| `null` | `PARAM_NULL` | +| other | `PARAM_STR` | + +## Prepare options + +Two layers: connection-wide `queryOptions` and per-call `$options`. The +per-call value wins: + +```php +use PDO; + +$db = new Connection([ + 'queryOptions' => [PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY], +]); + +$db->query( + 'SELECT * FROM big_table', + null, + [PDO::ATTR_CURSOR => PDO::CURSOR_SCROLL] // overrides for this call only +); +``` + +> **Why `+` not `array_merge`?** PDO prepare options use integer keys. In +> 1.x the merge was done with `array_merge`, which renumbers integer keys +> and silently broke the option passing. 2.x uses union (`+`) so positional +> integrity is preserved. + +## Error handling + +`Connection::query()` re-throws whatever PDO raises. With the default +`ERRMODE_EXCEPTION`, that is normally a `PDOException`: + +```php +use InitORM\DBAL\Connection\Exceptions\SQLExecuteException; + +try { + $db->query('SELECT * FROM nope'); +} catch (SQLExecuteException $e) { + // raised when prepare/execute themselves report failure +} catch (\PDOException $e) { + // raised by PDO (most common case under ERRMODE_EXCEPTION) +} +``` + +The failure is forwarded to your logger before the exception bubbles up, so +you do not need to log inside the catch. + +## Query logging + +Enable the in-memory buffer to inspect what ran during a request: + +```php +$db = new Connection(['queryLogs' => true]); +$db->query('SELECT 1'); +$db->query('SELECT 2'); + +$db->getQueryLogs(); +/* +[ + ['query' => 'SELECT 1', 'args' => null, 'timer' => 0.000123], + ['query' => 'SELECT 2', 'args' => null, 'timer' => 0.000087], +] +*/ +``` + +The buffer lives in process memory — clear or persist it yourself before it +grows unbounded. + +## What's next + +- [04 · DataMapper](04-data-mapper.md) for everything you can do with the + returned wrapper. diff --git a/docs/04-data-mapper.md b/docs/04-data-mapper.md new file mode 100644 index 0000000..839118d --- /dev/null +++ b/docs/04-data-mapper.md @@ -0,0 +1,90 @@ +# 04 · DataMapper + +`InitORM\DBAL\DataMapper\DataMapper` wraps a single `PDOStatement` and adds +a fluent fetch-mode + result-extraction surface. + +## Lifecycle + +You never instantiate `DataMapper` directly under normal use — `Connection::query()` +hands you one already prepared and executed. + +If you need to drive a raw `PDOStatement` (e.g. when integrating with code +that already holds one), use the factory: + +```php +use InitORM\DBAL\DataMapper\DataMapperFactory; + +$mapper = (new DataMapperFactory())->createDataMapper($pdoStatement); +``` + +## Fetch modes + +| Method | PDO mode | Returns | +|---------------|-------------------|--------------------------------------| +| `asAssoc()` | `FETCH_ASSOC` | Associative array per row. | +| `asObject()` | `FETCH_OBJ` | `stdClass` per row. | +| `asObject($t)`| `FETCH_INTO` | Populates the supplied object. | +| `asClass($c)` | `FETCH_CLASS` | Instance of `$c` per row. | +| `asArray()` | `FETCH_BOTH` | Both numeric and named keys. | +| `asBoth()` | alias of `asArray`| | +| `asLazy()` | `FETCH_LAZY` | Row object lazily populating fields. | + +Fetch mode is sticky on the underlying statement — set it once, then call +`row()` / `rows()` as many times as you need. + +## Fetching results + +```php +$mapper = $db->query('SELECT id, name FROM users'); + +$first = $mapper->asAssoc()->row(); // first row or null +$all = $mapper->asAssoc()->rows(); // every remaining row (possibly []) +$count = $mapper->numRows(); // driver-specific; see below +``` + +> **`rows()` returns `[]`, not `null`, when there are no rows.** This is a +> 2.0 behaviour change. Existing code that compared against `null` should +> compare against `[]` instead. + +> **`numRows()`** forwards to `PDOStatement::rowCount()`. For SELECT +> statements this is only reliable on drivers that buffer the entire +> result set (MySQL with the default attributes; PostgreSQL too). For +> SQLite, prefer running a separate `SELECT COUNT(*)`. + +## Binding values manually + +```php +$mapper = $db->query('SELECT id FROM users WHERE id = :id'); // no params yet +// ... + +$mapper->bindValue('id', 7); // single +$mapper->bindValues(['id' => 7]); // bulk +$mapper->execute(); +``` + +The leading colon on the key is optional. + +## Forwarding to PDOStatement + +Anything you call on the mapper that isn't defined above is forwarded to +the underlying `PDOStatement`: + +```php +$mapper->closeCursor(); // PDOStatement::closeCursor() +$mapper->columnCount(); // PDOStatement::columnCount() +$mapper->getColumnMeta(0); // PDOStatement::getColumnMeta() +``` + +Property access is forwarded too: + +```php +$mapper->queryString; // == PDOStatement::$queryString +``` + +## When you really need the raw statement + +```php +$stmt = $mapper->getStatement(); +``` + +Useful for libraries that expect a `PDOStatement` rather than a wrapper. diff --git a/docs/05-transactions.md b/docs/05-transactions.md new file mode 100644 index 0000000..9591721 --- /dev/null +++ b/docs/05-transactions.md @@ -0,0 +1,59 @@ +# 05 · Transactions + +DBAL does not add an explicit transaction API. The PDO methods are +forwarded directly: + +```php +$db->beginTransaction(); +try { + $db->query( + 'INSERT INTO orders (user_id, total) VALUES (:user, :total)', + ['user' => 1, 'total' => 100] + ); + $db->query( + 'INSERT INTO order_items (order_id, sku, qty) VALUES (:id, :sku, :qty)', + ['id' => $db->lastInsertId(), 'sku' => 'X-1', 'qty' => 2] + ); + $db->commit(); +} catch (\Throwable $e) { + if ($db->inTransaction()) { + $db->rollBack(); + } + throw $e; +} +``` + +`beginTransaction`, `inTransaction`, `commit`, `rollBack`, `lastInsertId`, +and friends all work through `Connection::__call()` because they exist on +the underlying PDO instance. + +## Nesting & savepoints + +PDO itself does not implement savepoints portably. If you need them, run +the driver-specific SQL directly: + +```php +$db->getPDO()->exec('SAVEPOINT sp1'); +// ... work ... +$db->getPDO()->exec('ROLLBACK TO SAVEPOINT sp1'); +``` + +## Persistent connections — read this once + +If you set `PDO::ATTR_PERSISTENT => true`, several gotchas appear: + +1. **Aborted transactions can leak.** A request that ends mid-transaction + leaves the connection in an open-transaction state for the next request + that picks it up. +2. **Prepared-statement caches survive across requests** on some drivers, + which can confuse migrations. +3. `Connection::disconnect()` returns the connection to the pool rather + than closing it. + +The library default is `false` for these reasons. Opt in deliberately, and +always wrap every transaction with the `try/catch + rollBack` pattern +above. + +## What's next + +- [06 · Logging](06-logging.md) — capture every query and every failure. diff --git a/docs/06-logging.md b/docs/06-logging.md new file mode 100644 index 0000000..328a219 --- /dev/null +++ b/docs/06-logging.md @@ -0,0 +1,98 @@ +# 06 · Logging + +Two independent facilities: + +| Facility | Configured by | Purpose | +|-------------------|------------------|------------------------------------------------------| +| Query log buffer | `queryLogs` flag | Every executed query is appended in memory. | +| Critical logger | `log` credential | Receives failure messages emitted by `createLog()`. | + +## Query log buffer + +```php +$db = new Connection(['queryLogs' => true]); +// or at runtime: +$db->setQueryLogs(true); + +$db->query('SELECT 1'); +$db->getQueryLogs(); +// [['query' => 'SELECT 1', 'args' => null, 'timer' => 0.000123]] +``` + +Each entry is `['query' => string, 'args' => ?array, 'timer' => float]`. +`timer` is the wall-clock time spent inside `Connection::query()`, in +seconds. + +The buffer lives in process memory and never rotates — if you keep it on +for an entire long-running worker, drain it periodically. + +## The `log` credential + +`createLog()` accepts a message and an optional context array, then +dispatches to whichever of these your `log` credential happens to be: + +### 1. A `Psr\Log\LoggerInterface` + +```php +use Monolog\Logger; + +$db = new Connection(['log' => new Logger('db')]); +``` + +Messages are sent at `critical` level with `{placeholder}` tokens left +intact for the PSR-3 interpolator. + +### 2. A callable + +```php +$db = new Connection([ + 'log' => static function (string $message): void { + error_log($message); + }, +]); +``` + +Placeholders are replaced before the callable runs. + +### 3. A file path with placeholders + +```php +$db = new Connection([ + 'log' => __DIR__ . '/logs/db-{date}.log', +]); +``` + +Supported placeholders: `{timestamp}`, `{date}`, `{datetime}`, `{year}`, +`{month}`, `{day}`, `{hour}`, `{minute}`, `{second}`. + +### 4. Any object with a `critical()` method + +For codebases that ship their own logger but didn't pull in `psr/log`. + +```php +$db = new Connection(['log' => $myLogger]); // duck-typed +``` + +## Failure logging + +`Connection::query()` calls `createLog()` automatically when the query +fails. The message is: + +``` + +SQL : +``` + +If `debug` is `true`, a JSON dump of the bound parameters is appended: + +``` +SQL : SELECT * FROM users WHERE id = :id +PARAMS : {"id":1} +``` + +Set `debug` once at construction or via `setDebug(true)` at runtime. + +## What's next + +- [07 · Factories & DI](07-factories-and-di.md) for wiring connections in a + container. diff --git a/docs/07-factories-and-di.md b/docs/07-factories-and-di.md new file mode 100644 index 0000000..2284807 --- /dev/null +++ b/docs/07-factories-and-di.md @@ -0,0 +1,88 @@ +# 07 · Factories & DI + +DBAL ships two small factories so you can build connections and mappers +through a container without hand-wiring `new`. + +## `ConnectionFactory` + +```php +use InitORM\DBAL\Connection\ConnectionFactory; +use InitORM\DBAL\Connection\Interfaces\ConnectionFactoryInterface; + +$factory = new ConnectionFactory(); +$db = $factory->createConnection([ + 'driver' => 'mysql', + 'host' => 'db', + 'database' => 'shop', + // ... +]); +``` + +`ConnectionFactory` implements `ConnectionFactoryInterface`. Bind that +interface in your container and request it instead of the concrete class — +this lets you swap in a fake factory for tests: + +```php +// PHP-DI / Symfony / etc. +$container->bind(ConnectionFactoryInterface::class, ConnectionFactory::class); +``` + +## `DataMapperFactory` + +`Connection::query()` uses a `DataMapperFactory` internally to wrap each +prepared statement. You can replace it through the second constructor +argument — handy in tests: + +```php +use InitORM\DBAL\Connection\Connection; +use InitORM\DBAL\DataMapper\Interfaces\DataMapperFactoryInterface; + +$fakeFactory = new class implements DataMapperFactoryInterface { + public function createDataMapper(\PDOStatement $statement): \InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface + { + return new MyTracingDataMapper($statement); + } +}; + +$db = new Connection($credentials, $fakeFactory); +``` + +## `DsnBuilder` + +The DSN composer is also injectable via the third constructor argument. +Use it when you need to support a driver DBAL doesn't know about, or to +adapt the DSN shape for a connection-pooling proxy: + +```php +use InitORM\DBAL\Connection\Support\DsnBuilder; + +$customBuilder = new class extends DsnBuilder { + public function build(array $credentials): string + { + if (($credentials['driver'] ?? '') === 'proxysql') { + return sprintf('mysql:host=proxysql;port=6033;dbname=%s', $credentials['database']); + } + return parent::build($credentials); + } +}; + +$db = new Connection($credentials, null, $customBuilder); +``` + +## Singleton vs per-request + +Connections are cheap to *construct* (no socket opens until the first +query) but expensive to *open*. For long-running workers, build one +Connection per database and share it. For PHP-FPM, build one per request — +the OS connection is gone when the request ends anyway. + +If you need both a shared and a fresh instance, call `clone()`: + +```php +$primary = $container->get(ConnectionInterface::class); +$transient = $primary->clone()->setHost('replica'); +``` + +## What's next + +- [08 · Exceptions](08-exceptions.md) — the exception hierarchy in full. diff --git a/docs/08-exceptions.md b/docs/08-exceptions.md new file mode 100644 index 0000000..0b13010 --- /dev/null +++ b/docs/08-exceptions.md @@ -0,0 +1,96 @@ +# 08 · Exceptions + +DBAL throws four classes of exception, all in +`InitORM\DBAL\Connection\Exceptions` and `InitORM\DBAL\DataMapper\Exceptions`. + +## Hierarchy + +``` +\Exception +└── ConnectionException + ├── SQLExecuteException + └── ValidConnectionAvailableException [deprecated alias parent] + └── ConnectionAlreadyEstablishedException [preferred name] + +\Exception +└── DataMapperException + +\InvalidArgumentException +├── ConnectionInvalidArgumentException +└── DataMapperInvalidArgumentException +``` + +## When each is thrown + +### `ConnectionException` + +The base exception for the Connection layer. Raised by `connect()` when +PDO itself rejects the credentials. Always preserved as the `previous` +exception so the original PDO message is reachable. + +```php +use InitORM\DBAL\Connection\Exceptions\ConnectionException; + +try { + $db->getPDO(); +} catch (ConnectionException $e) { + error_log($e->getMessage()); + error_log($e->getPrevious()?->getMessage() ?? ''); +} +``` + +### `SQLExecuteException` + +Raised by `Connection::query()` when `prepare()` returns `false` or +`execute()` returns `false`. In practice, when running under +`ERRMODE_EXCEPTION` (the default), PDO will normally raise a +`PDOException` *before* this gets a chance to fire — but the type is +useful when you opt out of exception mode. + +### `ConnectionAlreadyEstablishedException` + +Raised when a setter (`setHost`, `setDatabase`, `setCharset`, etc.) is +called after the underlying PDO has already been instantiated. The fix is +to `disconnect()` first or build a fresh Connection via `clone()`. + +```php +use InitORM\DBAL\Connection\Exceptions\ConnectionAlreadyEstablishedException; +use InitORM\DBAL\Connection\Exceptions\ValidConnectionAvailableException; + +try { + $db->setDatabase('other'); +} catch (ConnectionAlreadyEstablishedException $e) { + // preferred — new name +} +// Or, for compatibility with code written against 1.x: +try { + $db->setDatabase('other'); +} catch (ValidConnectionAvailableException $e) { + // also works — old name remains the parent class +} +``` + +### `ConnectionInvalidArgumentException` + +Raised by `setCharset()`, `setDriver()`, and friends when the supplied +identifier contains characters outside `[A-Za-z0-9_]`. This guards the +`SET NAMES ... COLLATE ...` statement, which cannot use bound parameters. + +### `DataMapperException` / `DataMapperInvalidArgumentException` + +Reserved for future use; the current `DataMapper` implementation does not +raise them on its own (PDO exceptions bubble up unwrapped). + +## Catching everything from DBAL + +There is no single root class deliberately — `ConnectionException` and +`DataMapperException` are siblings under `\Exception`. For a blanket +catch, use `\Throwable`: + +```php +try { + $db->query('SELECT 1'); +} catch (\Throwable $e) { + // any DBAL or PDO failure lands here +} +``` diff --git a/docs/09-recipes.md b/docs/09-recipes.md new file mode 100644 index 0000000..3f987b0 --- /dev/null +++ b/docs/09-recipes.md @@ -0,0 +1,144 @@ +# 09 · Recipes + +Small, copy-pastable patterns for common situations. + +## Multi-database routing + +```php +use InitORM\DBAL\Connection\Connection; + +$primary = new Connection($writeConfig); +$replica = $primary->clone() + ->setHost('replica.internal') + ->setUsername('readonly'); + +function dbFor(string $sql): Connection { + global $primary, $replica; + return preg_match('/^\s*SELECT\b/i', $sql) ? $replica : $primary; +} +``` + +## Runtime database switch + +```php +$db->disconnect(); +$db->setDatabase('reporting'); +$db->getPDO(); +``` + +`setDatabase()` throws while the connection is open — `disconnect()` first. + +## Streaming a large result set + +```php +$mapper = $db->query('SELECT * FROM events ORDER BY id'); +$mapper->asAssoc(); +while (($row = $mapper->row()) !== null) { + handle($row); +} +$mapper->closeCursor(); +``` + +`row()` calls `PDOStatement::fetch()` under the hood — no buffering in +PHP-land beyond what the driver does itself. + +## Hydrating into a target class + +```php +final class User +{ + public int $id = 0; + public string $name = ''; +} + +$users = $db->query('SELECT id, name FROM users') + ->asClass(User::class) + ->rows(); // array +``` + +Be aware that `FETCH_CLASS` writes properties *before* the constructor +runs, so any setup logic in `__construct` sees populated state. + +## Testing with SQLite in-memory + +```php +final class UserRepositoryTest extends \PHPUnit\Framework\TestCase +{ + private \InitORM\DBAL\Connection\Connection $db; + + protected function setUp(): void + { + $this->db = new \InitORM\DBAL\Connection\Connection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'charset' => '', + ]); + $this->db->getPDO()->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)'); + } +} +``` + +## Capturing queries during a request + +```php +$db->setQueryLogs(true); + +// ... application code ... + +register_shutdown_function(static function () use ($db): void { + file_put_contents( + '/var/log/app/queries.log', + json_encode($db->getQueryLogs(), JSON_PRETTY_PRINT) + ); +}); +``` + +## Replacing the DSN strategy + +```php +use InitORM\DBAL\Connection\Connection; +use InitORM\DBAL\Connection\Support\DsnBuilder; + +$builder = new class extends DsnBuilder { + public function build(array $credentials): string + { + // Route writes through a single-writer proxy, reads direct. + if (($credentials['_role'] ?? 'r') === 'w') { + return 'mysql:host=writer-proxy;port=6033;dbname=' . $credentials['database']; + } + return parent::build($credentials); + } +}; + +$write = new Connection(['_role' => 'w', /* ... */], null, $builder); +$read = new Connection(['_role' => 'r', /* ... */], null, $builder); +``` + +## Composing with a PSR-3 logger + +```php +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +$logger = new Logger('db'); +$logger->pushHandler(new StreamHandler('php://stderr')); + +$db = new \InitORM\DBAL\Connection\Connection([ + 'log' => $logger, + // ... +]); +``` + +Failures are logged at `critical` level with PSR-3 placeholders intact. + +## Forwarding to PDO without leaking the wrapper + +Sometimes you need to hand a raw `PDO` to a third-party library: + +```php +$thirdParty->setPdo($db->getPDO()); +``` + +The wrapper does not own the PDO instance — there is no harm in passing +the raw handle around, as long as you do not also call `disconnect()` on +the wrapper while the third party still holds the handle. diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..5df8e88 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,34 @@ + + + + + + tests + + + + + + src + + + src/*/Interfaces + + + + + + + + diff --git a/src/Connection/Connection.php b/src/Connection/Connection.php new file mode 100644 index 0000000..97456ea --- /dev/null +++ b/src/Connection/Connection.php @@ -0,0 +1,474 @@ +, + * driver: string, + * host: string, + * port: int|string, + * database: string, + * queryOptions: array, + * log: mixed, + * debug: bool, + * queryLogs: bool + * } + */ + protected array $credentials = [ + 'dsn' => '', + 'username' => null, + 'password' => null, + 'charset' => 'utf8mb4', + 'collation' => null, + 'options' => [], + + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'port' => 3306, + 'database' => '', + + 'queryOptions' => [], + + 'log' => null, + 'debug' => false, + 'queryLogs' => false, + ]; + + private ?PDO $pdo = null; + + private DataMapperFactoryInterface $dataMapperFactory; + + private DsnBuilder $dsnBuilder; + + private QueryLogger $queryLogger; + + private Logger $logger; + + /** + * Identifiers (charset, collation, driver) must match this pattern to be + * safe for direct interpolation into a `SET NAMES ... COLLATE ...` + * statement — they cannot be bound via prepared statements. + */ + private const SAFE_IDENTIFIER = '/^[A-Za-z0-9_]+$/'; + + /** + * @param array $credentials Merged on top of the defaults. + */ + public function __construct( + array $credentials = [], + ?DataMapperFactoryInterface $dataMapperFactory = null, + ?DsnBuilder $dsnBuilder = null + ) { + $this->credentials = array_merge($this->credentials, $credentials); + + $this->dataMapperFactory = $dataMapperFactory ?? new DataMapperFactory(); + $this->dsnBuilder = $dsnBuilder ?? new DsnBuilder(); + $this->queryLogger = new QueryLogger((bool) $this->credentials['queryLogs']); + $this->logger = new Logger($this->credentials['log']); + } + + /** + * Forward unknown method calls to the underlying PDO instance. + * + * When PDO returns itself (chainable methods) the call is re-wrapped to + * return this Connection, preserving fluent chains across the wrapper + * boundary. + * + * @param array $arguments + * @return mixed + * @throws ConnectionException + */ + public function __call(string $name, array $arguments) + { + $result = $this->getPDO()->{$name}(...$arguments); + + return $result instanceof PDO ? $this : $result; + } + + public function clone(): self + { + return new self($this->credentials, $this->dataMapperFactory, $this->dsnBuilder); + } + + public function getPDO(): PDO + { + if ($this->pdo === null) { + $this->connect(); + } + + return $this->pdo; + } + + public function connect(): bool + { + try { + $this->pdo = new PDO( + $this->getDsn(), + $this->getUsername(), + $this->getPassword(), + $this->getOptions() + ); + + $this->applyCharsetAndCollation(); + + if (empty($this->credentials['driver'])) { + $this->credentials['driver'] = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + } + + return true; + } catch (Throwable $e) { + throw new ConnectionException($e->getMessage(), (int) $e->getCode(), $e); + } + } + + public function disconnect(): bool + { + $this->pdo = null; + + return true; + } + + public function setDatabase(string $database): self + { + $this->guardNotConnected(); + $this->credentials['database'] = $database; + + return $this; + } + + public function getDatabase(): ?string + { + $database = $this->credentials['database'] ?? null; + + return $database === '' ? null : $database; + } + + public function setHost(string $host): self + { + $this->guardNotConnected(); + $this->credentials['host'] = $host; + + return $this; + } + + public function getHost(): ?string + { + return $this->credentials['host'] ?? null; + } + + public function setPort($port): self + { + $this->guardNotConnected(); + $this->credentials['port'] = $port; + + return $this; + } + + /** + * @return int|string|null + */ + public function getPort() + { + return $this->credentials['port'] ?? null; + } + + public function setCharset(string $charset = 'utf8mb4', ?string $collation = null): self + { + $this->guardNotConnected(); + $this->assertSafeIdentifier($charset, 'charset'); + if ($collation !== null) { + $this->assertSafeIdentifier($collation, 'collation'); + } + + $this->credentials['charset'] = $charset; + $this->credentials['collation'] = $collation; + + return $this; + } + + public function getCharset(): string + { + return $this->credentials['charset']; + } + + public function getCollation(): ?string + { + return $this->credentials['collation']; + } + + public function setDsn(string $dsn): self + { + $this->guardNotConnected(); + $this->credentials['dsn'] = $dsn; + + return $this; + } + + public function getDsn(): string + { + if (empty($this->credentials['dsn'])) { + $this->credentials['dsn'] = $this->dsnBuilder->build($this->credentials); + } + + return $this->credentials['dsn']; + } + + public function setUsername(?string $username): self + { + $this->guardNotConnected(); + $this->credentials['username'] = $username; + + return $this; + } + + public function getUsername(): ?string + { + return $this->credentials['username']; + } + + public function setPassword(?string $password = null): self + { + $this->guardNotConnected(); + $this->credentials['password'] = $password; + + return $this; + } + + public function getPassword(): ?string + { + return $this->credentials['password']; + } + + public function setDriver(string $driver): self + { + $this->guardNotConnected(); + $this->assertSafeIdentifier($driver, 'driver'); + $this->credentials['driver'] = $driver; + + return $this; + } + + public function getDriver(): string + { + return $this->credentials['driver']; + } + + public function setOptions(array $options = []): self + { + $this->guardNotConnected(); + if ($options !== []) { + $this->credentials['options'] = $options + $this->credentials['options']; + } + + return $this; + } + + /** + * @return array + */ + public function getOptions(): array + { + $defaults = [ + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_PERSISTENT => false, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + + return $this->credentials['options'] + $defaults; + } + + public function addQueryLog(string $query, ?array $args = null, ?float $startTime = null): void + { + $this->queryLogger->add($query, $args, $startTime); + } + + public function getQueryLogs(): array + { + return $this->queryLogger->all(); + } + + public function setQueryLogs(bool $status = false): self + { + $this->credentials['queryLogs'] = $status; + $this->queryLogger->enable($status); + + return $this; + } + + public function createLog(string $message, ?array $context = null): bool + { + // Pick up the latest user-supplied sink in case it was set after + // construction (e.g. setOptions-style late binding by frameworks). + $this->logger->setSink($this->credentials['log']); + + return $this->logger->write($message, $context); + } + + public function setDebug(bool $status = false): self + { + $this->credentials['debug'] = $status; + + return $this; + } + + public function getDebug(): bool + { + return $this->credentials['debug']; + } + + /** + * @throws ConnectionException + * @throws SQLExecuteException + */ + public function query(string $sqlQuery, ?array $parameters = null, ?array $options = null): DataMapperInterface + { + $startTime = microtime(true); + + // `+` keeps integer keys intact; `array_merge` would renumber them + // and break PDO::ATTR_* options (BUG-4). + $prepareOptions = ($options ?? []) + $this->credentials['queryOptions']; + + try { + $stmt = $this->getPDO()->prepare($sqlQuery, $prepareOptions); + if ($stmt === false) { + throw new SQLExecuteException('The SQL query could not be prepared: ' . $sqlQuery); + } + + $dataMapper = $this->dataMapperFactory->createDataMapper($stmt); + + if (!empty($parameters)) { + $dataMapper->bindValues($parameters); + } + + if (!$dataMapper->execute()) { + throw new SQLExecuteException('The SQL query could not be executed: ' . $sqlQuery); + } + + $this->addQueryLog($sqlQuery, $parameters, $startTime); + + return $dataMapper; + } catch (Throwable $e) { + $this->addQueryLog($sqlQuery, $parameters, $startTime); + $this->createLog($this->formatFailureMessage($e, $sqlQuery, $parameters)); + throw $e; + } + } + + /** + * @throws ConnectionAlreadyEstablishedException + */ + private function guardNotConnected(): void + { + if ($this->pdo !== null) { + throw new ConnectionAlreadyEstablishedException(); + } + } + + private function applyCharsetAndCollation(): void + { + if ($this->credentials['driver'] !== 'mysql') { + // SET NAMES / SET CHARACTER SET are MySQL-isms. PostgreSQL uses + // `client_encoding` (which we leave to the DSN), and SQLite has + // no character-set concept. Apply them only for MySQL. + return; + } + + $charset = $this->credentials['charset']; + $collation = $this->credentials['collation']; + if ($charset === '') { + return; + } + + // Charset values come from configuration but we still defend against + // accidental injection with a strict whitelist (BUG-7). + if (preg_match(self::SAFE_IDENTIFIER, $charset) !== 1) { + return; + } + + $pdo = $this->pdo; + if ($pdo === null) { + return; + } + + if (is_string($collation) && preg_match(self::SAFE_IDENTIFIER, $collation) === 1) { + $pdo->exec("SET NAMES '{$charset}' COLLATE '{$collation}'"); + } else { + $pdo->exec("SET NAMES '{$charset}'"); + } + + $pdo->exec("SET CHARACTER SET '{$charset}'"); + } + + /** + * @param array|null $parameters + */ + private function formatFailureMessage(Throwable $e, string $sqlQuery, ?array $parameters): string + { + $message = $e->getMessage() . PHP_EOL . 'SQL : ' . $sqlQuery; + + if ($this->credentials['debug'] && !empty($parameters)) { + $encoded = json_encode($parameters, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($encoded !== false) { + $message .= PHP_EOL . 'PARAMS : ' . $encoded; + } + } + + return $message; + } + + /** + * @throws ConnectionInvalidArgumentException + */ + private function assertSafeIdentifier(string $value, string $label): void + { + if (preg_match(self::SAFE_IDENTIFIER, $value) !== 1) { + throw new ConnectionInvalidArgumentException(sprintf( + 'Invalid %s "%s"; must match %s.', + $label, + $value, + self::SAFE_IDENTIFIER + )); + } + } +} diff --git a/src/Connection/ConnectionFactory.php b/src/Connection/ConnectionFactory.php new file mode 100644 index 0000000..b788090 --- /dev/null +++ b/src/Connection/ConnectionFactory.php @@ -0,0 +1,16 @@ + $options PDO attribute map (integer keys). + * @throws ConnectionAlreadyEstablishedException + */ + public function setOptions(array $options = []): self; + + /** + * @return array + */ + public function getOptions(): array; +} diff --git a/src/Connection/Interfaces/ConnectionFactoryInterface.php b/src/Connection/Interfaces/ConnectionFactoryInterface.php new file mode 100644 index 0000000..5c8679e --- /dev/null +++ b/src/Connection/Interfaces/ConnectionFactoryInterface.php @@ -0,0 +1,14 @@ + $credentials Merged on top of the default + * credentials inside the Connection constructor. + */ + public function createConnection(array $credentials = []): ConnectionInterface; +} diff --git a/src/Connection/Interfaces/ConnectionInterface.php b/src/Connection/Interfaces/ConnectionInterface.php new file mode 100644 index 0000000..24b67cd --- /dev/null +++ b/src/Connection/Interfaces/ConnectionInterface.php @@ -0,0 +1,65 @@ + true` the connection is returned + * to the pool rather than closed. + */ + public function disconnect(): bool; + + /** + * Prepare and execute `$sqlQuery` with optional named parameters and PDO + * prepare options, returning a {@see DataMapperInterface} wrapping the + * resulting statement. + * + * @param array|null $parameters Named parameters; the + * leading `:` on each key is optional. + * @param array|null $options PDO prepare options (integer keys). + * + * @throws SQLExecuteException When prepare/execute fails. + * @throws ConnectionException + */ + public function query(string $sqlQuery, ?array $parameters = null, ?array $options = null): DataMapperInterface; +} diff --git a/src/Connection/Interfaces/LoggableConnectionInterface.php b/src/Connection/Interfaces/LoggableConnectionInterface.php new file mode 100644 index 0000000..e478d25 --- /dev/null +++ b/src/Connection/Interfaces/LoggableConnectionInterface.php @@ -0,0 +1,46 @@ +|null $args + */ + public function addQueryLog(string $query, ?array $args = null, ?float $startTime = null): void; + + /** + * @return array|null, timer: float}> + */ + public function getQueryLogs(): array; + + public function setQueryLogs(bool $status = false): self; + + /** + * Forward a message to the configured `log` sink (PSR-3 logger, callable, + * file path, or `critical`-method duck type). Returns `false` when no + * sink is configured. + * + * Placeholders in `$context` follow PSR-3 conventions: `{key}` in the + * message is replaced with `(string) $context[$key]`. + * + * @param array|null $context + */ + public function createLog(string $message, ?array $context = null): bool; + + public function setDebug(bool $status = false): self; + + public function getDebug(): bool; +} diff --git a/src/Connection/Support/DsnBuilder.php b/src/Connection/Support/DsnBuilder.php new file mode 100644 index 0000000..2e9e121 --- /dev/null +++ b/src/Connection/Support/DsnBuilder.php @@ -0,0 +1,69 @@ +buildPgsql($credentials); + + default: + return $this->buildMysqlLike($driver, $credentials); + } + } + + /** + * @param array{host?: string, port?: int|string, database?: string, charset?: string} $c + */ + private function buildMysqlLike(string $driver, array $c): string + { + $parts = [ + 'host=' . ($c['host'] ?? '127.0.0.1'), + 'port=' . ($c['port'] ?? 3306), + ]; + if (!empty($c['database'])) { + $parts[] = 'dbname=' . $c['database']; + } + if (!empty($c['charset'])) { + $parts[] = 'charset=' . $c['charset']; + } + + return $driver . ':' . implode(';', $parts); + } + + /** + * @param array{host?: string, port?: int|string, database?: string} $c + */ + private function buildPgsql(array $c): string + { + $parts = [ + 'host=' . ($c['host'] ?? '127.0.0.1'), + 'port=' . ($c['port'] ?? 5432), + ]; + if (!empty($c['database'])) { + $parts[] = 'dbname=' . $c['database']; + } + + return 'pgsql:' . implode(';', $parts); + } +} diff --git a/src/Connection/Support/Logger.php b/src/Connection/Support/Logger.php new file mode 100644 index 0000000..cecaa12 --- /dev/null +++ b/src/Connection/Support/Logger.php @@ -0,0 +1,117 @@ +sink = $sink; + } + + /** + * @param mixed $sink + */ + public function setSink($sink): void + { + $this->sink = $sink; + } + + /** + * @param array|null $context + */ + public function write(string $message, ?array $context = null): bool + { + if ($this->sink === null || $this->sink === '' || $this->sink === false) { + return false; + } + + if ($this->sink instanceof LoggerInterface) { + $this->sink->critical($message, $context ?? []); + return true; + } + + $formatted = $context !== null && $context !== [] + ? $this->interpolate($message, $context) + : $message; + + if (is_callable($this->sink)) { + ($this->sink)($formatted); + return true; + } + + if (is_object($this->sink) && method_exists($this->sink, 'critical')) { + $this->sink->critical($formatted); + return true; + } + + if (is_string($this->sink)) { + return $this->writeToFile($this->sink, $formatted); + } + + return false; + } + + /** + * @param array $context + */ + private function interpolate(string $message, array $context): string + { + $replacements = []; + foreach ($context as $key => $value) { + if (is_array($value) || (is_object($value) && !$value instanceof Stringable)) { + continue; + } + $replacements['{' . $key . '}'] = $value === null ? '' : (string) $value; + } + + return strtr($message, $replacements); + } + + private function writeToFile(string $template, string $message): bool + { + $path = strtr($template, [ + '{timestamp}' => (string) time(), + '{date}' => date('Y-m-d'), + '{datetime}' => date('Y-m-d-H-i-s'), + '{year}' => date('Y'), + '{month}' => date('m'), + '{day}' => date('d'), + '{hour}' => date('H'), + '{minute}' => date('i'), + '{second}' => date('s'), + ]); + + $bytes = file_put_contents($path, $message . PHP_EOL, FILE_APPEND); + + return $bytes !== false; + } +} diff --git a/src/Connection/Support/QueryLogger.php b/src/Connection/Support/QueryLogger.php new file mode 100644 index 0000000..4138b18 --- /dev/null +++ b/src/Connection/Support/QueryLogger.php @@ -0,0 +1,70 @@ +|null, timer: float} + */ +final class QueryLogger +{ + /** @var array */ + private array $logs = []; + + private bool $enabled; + + public function __construct(bool $enabled = false) + { + $this->enabled = $enabled; + } + + public function enable(bool $enabled = true): void + { + $this->enabled = $enabled; + } + + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * @param array|null $args + */ + public function add(string $query, ?array $args = null, ?float $startTime = null): void + { + if (!$this->enabled) { + return; + } + + $timer = $startTime !== null + ? round(microtime(true) - $startTime, 6) + : 0.0; + + $this->logs[] = [ + 'query' => $query, + 'args' => $args, + 'timer' => $timer, + ]; + } + + /** + * @return array + */ + public function all(): array + { + return $this->logs; + } + + public function clear(): void + { + $this->logs = []; + } +} diff --git a/src/DataMapper/DataMapper.php b/src/DataMapper/DataMapper.php new file mode 100644 index 0000000..6f684b3 --- /dev/null +++ b/src/DataMapper/DataMapper.php @@ -0,0 +1,175 @@ +statement = $statement; + } + + /** + * @param array $arguments + * @return mixed + */ + public function __call(string $name, array $arguments) + { + $result = $this->statement->{$name}(...$arguments); + + return $result instanceof PDOStatement ? $this : $result; + } + + /** + * @return mixed + */ + public function __get(string $name) + { + return $this->statement->{$name}; + } + + public function __isset(string $name): bool + { + return isset($this->statement->{$name}); + } + + public function getStatement(): PDOStatement + { + return $this->statement; + } + + public function execute(?array $params = null): bool + { + return $this->statement->execute($params); + } + + public function getQuery(): string + { + return $this->statement->queryString; + } + + public function bindValue(string $key, $value): bool + { + $key = ':' . ltrim($key, ':'); + + return $this->statement->bindValue($key, $value, $this->bind($value)); + } + + public function bindValues(array $fields): bool + { + foreach ($fields as $key => $value) { + if (!$this->bindValue((string) $key, $value)) { + return false; + } + } + + return true; + } + + public function bind($value): int + { + if (is_bool($value)) { + return PDO::PARAM_BOOL; + } + if (is_int($value)) { + return PDO::PARAM_INT; + } + if (is_null($value)) { + return PDO::PARAM_NULL; + } + + return PDO::PARAM_STR; + } + + public function numRows(): int + { + return $this->statement->rowCount(); + } + + public function asClass(?string $class = null): self + { + $this->statement->setFetchMode(PDO::FETCH_CLASS, $class ?? \stdClass::class); + + return $this; + } + + public function asObject(?object $obj = null): self + { + if ($obj !== null) { + $this->statement->setFetchMode(PDO::FETCH_INTO, $obj); + } else { + $this->statement->setFetchMode(PDO::FETCH_OBJ); + } + + return $this; + } + + public function asAssoc(): self + { + $this->statement->setFetchMode(PDO::FETCH_ASSOC); + + return $this; + } + + public function asLazy(): self + { + $this->statement->setFetchMode(PDO::FETCH_LAZY); + + return $this; + } + + public function asArray(): self + { + $this->statement->setFetchMode(PDO::FETCH_BOTH); + + return $this; + } + + public function asBoth(): self + { + return $this->asArray(); + } + + /** + * @return array|object|null + */ + public function row() + { + $row = $this->statement->fetch(); + + return $row === false ? null : $row; + } + + /** + * @return array|object> + */ + public function rows(): array + { + $rows = $this->statement->fetchAll(); + + return $rows === false ? [] : $rows; + } +} diff --git a/src/DataMapper/DataMapperFactory.php b/src/DataMapper/DataMapperFactory.php new file mode 100644 index 0000000..ebbb837 --- /dev/null +++ b/src/DataMapper/DataMapperFactory.php @@ -0,0 +1,17 @@ +|null $params Optional named parameters; if supplied, + * these are passed straight to {@see PDOStatement::execute()} and override any + * previously bound values. + */ + public function execute(?array $params = null): bool; + + /** + * Return the SQL string the underlying statement was prepared with. + */ + public function getQuery(): string; + + /** + * Bind a single named value, automatically choosing the PDO type via + * {@see self::bind()}. The leading `:` on `$key` is optional. + */ + public function bindValue(string $key, $value): bool; + + /** + * Bind every entry in `$fields` as a named value. Returns `true` once all + * binds succeed. + * + * @param array $fields + */ + public function bindValues(array $fields): bool; + + /** + * Pick the PDO `PARAM_*` constant for `$value`. + * + * - `bool` → `PARAM_BOOL` + * - `int` → `PARAM_INT` + * - `null` → `PARAM_NULL` + * - anything else → `PARAM_STR` + * + * @param mixed $value + */ + public function bind($value): int; + + /** + * Forward to {@see PDOStatement::rowCount()}. Driver-specific: for SELECT + * queries the return value is only reliable on drivers that buffer + * results (e.g. MySQL). + */ + public function numRows(): int; + + /** + * Set fetch mode to `PDO::FETCH_CLASS`. Subsequent fetches return + * instances of `$class` (or stdClass when null). + */ + public function asClass(?string $class = null): self; + + /** + * Set fetch mode to either `FETCH_INTO` (when `$obj` is supplied) or + * `FETCH_OBJ`. + */ + public function asObject(?object $obj = null): self; + + public function asAssoc(): self; + + public function asLazy(): self; + + public function asArray(): self; + + /** + * Alias of {@see self::asArray()}. + */ + public function asBoth(): self; + + /** + * Fetch the next row, returning `null` when no row is available. + * + * @return array|object|null + */ + public function row(); + + /** + * Fetch every remaining row as an array (possibly empty). + * + * @return array|object> + */ + public function rows(): array; +} diff --git a/tests/Connection/ConnectionFactoryTest.php b/tests/Connection/ConnectionFactoryTest.php new file mode 100644 index 0000000..38f049e --- /dev/null +++ b/tests/Connection/ConnectionFactoryTest.php @@ -0,0 +1,23 @@ +createConnection([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ]); + + self::assertInstanceOf(ConnectionInterface::class, $connection); + self::assertSame('sqlite', $connection->getDriver()); + } +} diff --git a/tests/Connection/ConnectionTest.php b/tests/Connection/ConnectionTest.php new file mode 100644 index 0000000..08f8b51 --- /dev/null +++ b/tests/Connection/ConnectionTest.php @@ -0,0 +1,448 @@ +getProperty('pdo'); + $pdo->setAccessible(true); + + self::assertNull($pdo->getValue($connection)); + + $connection->getPDO(); + self::assertInstanceOf(PDO::class, $pdo->getValue($connection)); + } + + public function test_setter_throws_when_connection_is_already_open(): void + { + $connection = SqliteHelper::makeConnection(); + $connection->getPDO(); + + $this->expectException(ConnectionAlreadyEstablishedException::class); + $connection->setDatabase(':memory:'); + } + + /** + * The deprecated alias must still be catchable. + */ + public function test_deprecated_exception_alias_is_thrown(): void + { + $connection = SqliteHelper::makeConnection(); + $connection->getPDO(); + + $this->expectException(ValidConnectionAvailableException::class); + $connection->setHost('db'); + } + + public function test_disconnect_releases_pdo_reference(): void + { + $connection = SqliteHelper::makeConnection(); + $connection->getPDO(); + + self::assertTrue($connection->disconnect()); + + // After disconnect, setters work again. + $connection->setDatabase(':memory:'); + self::assertSame(':memory:', $connection->getDatabase()); + } + + /** + * Regression for BUG-1 + BUG-2: the auto-built DSN must include the + * database name (not the charset value) and the build branch must + * actually run when no explicit DSN was supplied. + */ + public function test_auto_builds_dsn_when_none_is_provided(): void + { + $connection = new Connection([ + 'driver' => 'mysql', + 'host' => 'db', + 'port' => 3306, + 'database' => 'app', + 'charset' => 'utf8mb4', + ]); + + self::assertSame('mysql:host=db;port=3306;dbname=app;charset=utf8mb4', $connection->getDsn()); + } + + public function test_uses_user_supplied_dsn_verbatim(): void + { + $connection = new Connection(['dsn' => 'mysql:host=x;dbname=y']); + + self::assertSame('mysql:host=x;dbname=y', $connection->getDsn()); + } + + public function test_query_returns_a_data_mapper(): void + { + $connection = SqliteHelper::makeConnection(); + SqliteHelper::seedUsers($connection); + + $mapper = $connection->query('SELECT id, name FROM users WHERE id = :id', ['id' => 1]); + + self::assertInstanceOf(DataMapperInterface::class, $mapper); + $row = $mapper->asAssoc()->row(); + self::assertSame('Alice', $row['name']); + } + + public function test_query_rejects_invalid_sql_with_sql_execute_exception(): void + { + $connection = SqliteHelper::makeConnection(); + SqliteHelper::seedUsers($connection); + + $this->expectException(\PDOException::class); + $connection->query('SELECT * FROM nonexistent_table'); + } + + public function test_query_logs_failures_to_the_user_supplied_logger(): void + { + $captured = []; + $connection = SqliteHelper::makeConnection([ + 'log' => static function (string $message) use (&$captured): void { + $captured[] = $message; + }, + ]); + SqliteHelper::seedUsers($connection); + + try { + $connection->query('SELECT * FROM nonexistent_table'); + self::fail('Expected the query to throw'); + } catch (\Throwable $e) { + // expected + } + + self::assertNotEmpty($captured); + self::assertStringContainsString('SELECT * FROM nonexistent_table', $captured[0]); + } + + /** + * Regression for BUG-5: a previous version called `strtr($sql, $params)` + * in the catch block, which raised TypeError when any parameter value + * was not a string. The new implementation must serialise parameters + * defensively and never swallow the original exception. + */ + public function test_query_failure_log_handles_non_string_parameters(): void + { + $captured = []; + $connection = SqliteHelper::makeConnection([ + 'debug' => true, + 'log' => static function (string $message) use (&$captured): void { + $captured[] = $message; + }, + ]); + SqliteHelper::seedUsers($connection); + + try { + $connection->query( + 'SELECT * FROM nonexistent_table WHERE id = :id AND on = :on AND meta = :meta', + ['id' => 1, 'on' => true, 'meta' => null] + ); + self::fail('Expected the query to throw'); + } catch (\Throwable $e) { + // expected — the point is that the catch block did not itself + // raise a TypeError. + } + + self::assertNotEmpty($captured); + self::assertStringContainsString('"id":1', $captured[0]); + } + + public function test_query_records_log_entry_when_enabled(): void + { + $connection = SqliteHelper::makeConnection(['queryLogs' => true]); + $connection->setQueryLogs(true); + SqliteHelper::seedUsers($connection); + + $connection->query('SELECT 1'); + + $logs = $connection->getQueryLogs(); + self::assertNotEmpty($logs); + $queries = array_column($logs, 'query'); + self::assertContains('SELECT 1', $queries); + } + + /** + * Regression for BUG-4: `array_merge` would renumber the integer PDO + * option keys. We need positional integrity, so `+` must be used. + */ + public function test_query_passes_user_prepare_options_into_pdo_prepare(): void + { + $connection = SqliteHelper::makeConnection([ + 'queryOptions' => [PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY], + ]); + SqliteHelper::seedUsers($connection); + + // No assertion failure means PDO accepted the options array intact. + // We additionally probe with a per-call options override. + $mapper = $connection->query( + 'SELECT id FROM users WHERE id = :id', + ['id' => 1], + [PDO::ATTR_CURSOR => PDO::CURSOR_FWDONLY] + ); + + // pdo_sqlite returned integer columns as strings before PHP 8.1, + // so compare loosely on the value while still proving the bind + // and the prepare options round-tripped. + $row = $mapper->asAssoc()->row(); + self::assertIsArray($row); + self::assertSame(['id'], array_keys($row)); + self::assertSame(1, (int) $row['id']); + } + + public function test_forwards_pdo_methods_via_call(): void + { + $connection = SqliteHelper::makeConnection(); + SqliteHelper::seedUsers($connection); + + $connection->query("INSERT INTO users (name, email) VALUES ('Carol', 'carol@example.com')"); + self::assertGreaterThan(0, (int) $connection->lastInsertId()); + } + + public function test_clone_returns_a_disconnected_instance_with_same_credentials(): void + { + $original = SqliteHelper::makeConnection(); + $original->getPDO(); + + $clone = $original->clone(); + self::assertNotSame($original, $clone); + + $ref = new \ReflectionClass($clone); + $pdo = $ref->getProperty('pdo'); + $pdo->setAccessible(true); + self::assertNull($pdo->getValue($clone)); + + self::assertSame($original->getDriver(), $clone->getDriver()); + } + + public function test_setCharset_rejects_unsafe_identifiers(): void + { + $connection = SqliteHelper::makeConnection(); + + $this->expectException(ConnectionInvalidArgumentException::class); + $connection->setCharset("utf8mb4'; DROP TABLE users; --"); + } + + public function test_persistent_default_is_false(): void + { + $connection = SqliteHelper::makeConnection(); + $options = $connection->getOptions(); + + self::assertFalse($options[PDO::ATTR_PERSISTENT]); + } + + public function test_user_options_override_defaults(): void + { + $connection = SqliteHelper::makeConnection([ + 'options' => [PDO::ATTR_PERSISTENT => true], + ]); + + self::assertTrue($connection->getOptions()[PDO::ATTR_PERSISTENT]); + } + + public function test_createLog_returns_false_when_no_sink_is_configured(): void + { + $connection = SqliteHelper::makeConnection(); + + self::assertFalse($connection->createLog('hello')); + } + + public function test_createLog_uses_psr3_placeholders(): void + { + $captured = ''; + $connection = SqliteHelper::makeConnection([ + 'log' => static function (string $message) use (&$captured): void { + $captured = $message; + }, + ]); + + self::assertTrue($connection->createLog('user {id} failed', ['id' => 7])); + self::assertSame('user 7 failed', $captured); + } + + public function test_setOptions_preserves_existing_user_options(): void + { + $connection = SqliteHelper::makeConnection(); + $connection->setOptions([PDO::ATTR_TIMEOUT => 5]); + $connection->setOptions([PDO::ATTR_CASE => PDO::CASE_LOWER]); + + $options = $connection->getOptions(); + self::assertSame(5, $options[PDO::ATTR_TIMEOUT]); + self::assertSame(PDO::CASE_LOWER, $options[PDO::ATTR_CASE]); + } + + public function test_connection_exception_is_thrown_for_bad_credentials(): void + { + $connection = new Connection([ + 'driver' => 'sqlite', + 'database' => '/this/path/should/not/exist/abc.sqlite', + 'options' => [], + ]); + + // sqlite happily creates the file if the directory exists; force a + // failure by giving it an unreachable path. + $this->expectException(ConnectionException::class); + $connection->getPDO(); + } + + /** + * Regression for BUG-3: when no driver is configured, `connect()` should + * read the actual driver name from the live PDO instance. + */ + public function test_connect_reads_driver_name_from_pdo_when_unset(): void + { + $connection = new Connection([ + 'dsn' => 'sqlite::memory:', + 'driver' => '', + 'charset' => '', + ]); + + $connection->getPDO(); + self::assertSame('sqlite', $connection->getDriver()); + } + + public function test_trivial_setters_and_getters_round_trip(): void + { + $connection = SqliteHelper::makeConnection(); + + $connection->setHost('h') + ->setPort(1234) + ->setDsn('sqlite::memory:') + ->setUsername('u') + ->setPassword('p') + ->setDriver('mysql') + ->setCharset('utf8mb4', 'utf8mb4_unicode_ci') + ->setDebug(true); + + self::assertSame('h', $connection->getHost()); + self::assertSame(1234, $connection->getPort()); + self::assertSame('sqlite::memory:', $connection->getDsn()); + self::assertSame('u', $connection->getUsername()); + self::assertSame('p', $connection->getPassword()); + self::assertSame('mysql', $connection->getDriver()); + self::assertSame('utf8mb4', $connection->getCharset()); + self::assertSame('utf8mb4_unicode_ci', $connection->getCollation()); + self::assertTrue($connection->getDebug()); + } + + public function test_setDriver_rejects_unsafe_identifier(): void + { + $connection = SqliteHelper::makeConnection(); + + $this->expectException(ConnectionInvalidArgumentException::class); + $connection->setDriver("mysql'; --"); + } + + public function test_setCharset_rejects_unsafe_collation(): void + { + $connection = SqliteHelper::makeConnection(); + + $this->expectException(ConnectionInvalidArgumentException::class); + $connection->setCharset('utf8', "utf8'; DROP"); + } + + public function test_getDatabase_returns_null_for_empty_string(): void + { + $connection = SqliteHelper::makeConnection(['database' => '']); + + self::assertNull($connection->getDatabase()); + } + + /** + * Run the MySQL-only charset branch by spoofing the credentials driver + * to 'mysql' after the underlying PDO has been opened against SQLite. + * SQLite rejects `SET NAMES`, so the method bubbles a PDOException — + * but every branch up to the `exec()` call is exercised. + */ + public function test_applyCharsetAndCollation_mysql_branch_with_collation(): void + { + $connection = SqliteHelper::makeConnection([ + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]); + $connection->getPDO(); + $this->spoofDriver($connection, 'mysql'); + + $this->expectException(\PDOException::class); + $this->invokePrivate($connection, 'applyCharsetAndCollation'); + } + + public function test_applyCharsetAndCollation_mysql_branch_without_collation(): void + { + $connection = SqliteHelper::makeConnection([ + 'charset' => 'utf8mb4', + 'collation' => null, + ]); + $connection->getPDO(); + $this->spoofDriver($connection, 'mysql'); + + $this->expectException(\PDOException::class); + $this->invokePrivate($connection, 'applyCharsetAndCollation'); + } + + public function test_applyCharsetAndCollation_returns_early_for_empty_charset(): void + { + $connection = SqliteHelper::makeConnection(['charset' => '']); + $connection->getPDO(); + $this->spoofDriver($connection, 'mysql'); + + // No exception — early return path. + $this->invokePrivate($connection, 'applyCharsetAndCollation'); + self::assertTrue(true); + } + + public function test_applyCharsetAndCollation_returns_early_for_unsafe_charset(): void + { + $connection = SqliteHelper::makeConnection(['charset' => 'utf8']); + $connection->getPDO(); + $this->spoofDriver($connection, 'mysql'); + // Bypass the setter validation by spoofing the charset directly. + $this->spoofCredential($connection, 'charset', "utf8' --"); + + // No exception — the regex guard catches it before exec. + $this->invokePrivate($connection, 'applyCharsetAndCollation'); + self::assertTrue(true); + } + + private function spoofDriver(Connection $connection, string $driver): void + { + $this->spoofCredential($connection, 'driver', $driver); + } + + private function spoofCredential(Connection $connection, string $key, $value): void + { + $ref = new \ReflectionObject($connection); + $prop = $ref->getProperty('credentials'); + $prop->setAccessible(true); + $creds = $prop->getValue($connection); + $creds[$key] = $value; + $prop->setValue($connection, $creds); + } + + /** + * @param array $args + * @return mixed + */ + private function invokePrivate(Connection $connection, string $method, array $args = []) + { + $ref = new \ReflectionObject($connection); + $m = $ref->getMethod($method); + $m->setAccessible(true); + + return $m->invokeArgs($connection, $args); + } +} diff --git a/tests/Connection/Exceptions/ExceptionHierarchyTest.php b/tests/Connection/Exceptions/ExceptionHierarchyTest.php new file mode 100644 index 0000000..cce8384 --- /dev/null +++ b/tests/Connection/Exceptions/ExceptionHierarchyTest.php @@ -0,0 +1,34 @@ +build([ + 'driver' => 'mysql', + 'host' => '127.0.0.1', + 'port' => 3306, + 'database' => 'app', + 'charset' => 'utf8mb4', + ]); + + self::assertSame('mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4', $dsn); + } + + public function test_omits_dbname_and_charset_when_empty(): void + { + $dsn = (new DsnBuilder())->build([ + 'driver' => 'mysql', + 'host' => 'db', + 'port' => 3306, + ]); + + self::assertSame('mysql:host=db;port=3306', $dsn); + } + + public function test_builds_pgsql_dsn_without_charset(): void + { + $dsn = (new DsnBuilder())->build([ + 'driver' => 'pgsql', + 'host' => 'pg', + 'port' => 5432, + 'database' => 'app', + 'charset' => 'utf8', + ]); + + self::assertSame('pgsql:host=pg;port=5432;dbname=app', $dsn); + } + + public function test_builds_sqlite_memory_dsn(): void + { + self::assertSame('sqlite::memory:', (new DsnBuilder())->build([ + 'driver' => 'sqlite', + 'database' => ':memory:', + ])); + } + + public function test_builds_sqlite_file_dsn(): void + { + self::assertSame('sqlite:/tmp/db.sqlite', (new DsnBuilder())->build([ + 'driver' => 'sqlite', + 'database' => '/tmp/db.sqlite', + ])); + } + + public function test_falls_back_to_mysql_defaults_when_driver_unknown(): void + { + $dsn = (new DsnBuilder())->build([ + 'driver' => 'mariadb', + 'database' => 'app', + ]); + + self::assertStringStartsWith('mariadb:host=127.0.0.1;port=3306;dbname=app', $dsn); + } +} diff --git a/tests/Connection/Support/LoggerTest.php b/tests/Connection/Support/LoggerTest.php new file mode 100644 index 0000000..3caea65 --- /dev/null +++ b/tests/Connection/Support/LoggerTest.php @@ -0,0 +1,132 @@ +write('boom')); + self::assertFalse((new Logger(null))->write('boom')); + self::assertFalse((new Logger(''))->write('boom')); + self::assertFalse((new Logger(false))->write('boom')); + } + + public function test_invokes_callable_sink_with_interpolated_message(): void + { + $captured = null; + $logger = new Logger(static function (string $message) use (&$captured): void { + $captured = $message; + }); + + $logger->write('user {id} failed', ['id' => 7]); + + self::assertSame('user 7 failed', $captured); + } + + public function test_invokes_psr3_logger_at_critical_level(): void + { + $sink = new class implements LoggerInterface { + public string $template = ''; + /** @var array */ + public array $context = []; + + public function emergency($message, array $context = []): void + { + } + public function alert($message, array $context = []): void + { + } + public function critical($message, array $context = []): void + { + $this->template = (string) $message; + $this->context = $context; + } + public function error($message, array $context = []): void + { + } + public function warning($message, array $context = []): void + { + } + public function notice($message, array $context = []): void + { + } + public function info($message, array $context = []): void + { + } + public function debug($message, array $context = []): void + { + } + public function log($level, $message, array $context = []): void + { + } + }; + + (new Logger($sink))->write('user {id} failed', ['id' => 99]); + + self::assertSame('user {id} failed', $sink->template); + self::assertSame(['id' => 99], $sink->context); + } + + public function test_invokes_duck_typed_critical_method(): void + { + $sink = new class { + public string $last = ''; + public function critical(string $message): void + { + $this->last = $message; + } + }; + + (new Logger($sink))->write('user {id} failed', ['id' => 1]); + + self::assertSame('user 1 failed', $sink->last); + } + + public function test_writes_to_a_file_path_with_placeholders(): void + { + $dir = sys_get_temp_dir() . '/dbal-logger-' . bin2hex(random_bytes(4)); + @mkdir($dir); + $path = $dir . '/log-{date}.log'; + + (new Logger($path))->write('first'); + (new Logger($path))->write('second'); + + $resolved = strtr($path, ['{date}' => date('Y-m-d')]); + self::assertFileExists($resolved); + $contents = file_get_contents($resolved); + self::assertNotFalse($contents); + self::assertStringContainsString('first', $contents); + self::assertStringContainsString('second', $contents); + + @unlink($resolved); + @rmdir($dir); + } + + public function test_returns_false_when_sink_is_an_unsupported_type(): void + { + // Integers, floats and plain objects without `critical()` fall through + // to the final `return false`. + self::assertFalse((new Logger(123))->write('boom')); + self::assertFalse((new Logger(new \stdClass()))->write('boom')); + } + + public function test_interpolation_skips_array_and_object_context_values(): void + { + $captured = null; + $logger = new Logger(static function (string $message) use (&$captured): void { + $captured = $message; + }); + + // Arrays and non-stringable objects must be ignored, not crash. + $logger->write('a={a} b={b} c={c}', ['a' => 'x', 'b' => ['list'], 'c' => new \stdClass()]); + + self::assertSame('a=x b={b} c={c}', $captured); + } +} diff --git a/tests/Connection/Support/QueryLoggerTest.php b/tests/Connection/Support/QueryLoggerTest.php new file mode 100644 index 0000000..f049154 --- /dev/null +++ b/tests/Connection/Support/QueryLoggerTest.php @@ -0,0 +1,66 @@ +add('SELECT 1', null, microtime(true)); + + self::assertSame([], $logger->all()); + } + + public function test_records_when_enabled(): void + { + $logger = new QueryLogger(true); + $logger->add('SELECT 1', ['id' => 7], microtime(true) - 0.01); + + $logs = $logger->all(); + self::assertCount(1, $logs); + self::assertSame('SELECT 1', $logs[0]['query']); + self::assertSame(['id' => 7], $logs[0]['args']); + self::assertGreaterThan(0.0, $logs[0]['timer']); + } + + public function test_enable_toggles_recording_at_runtime(): void + { + $logger = new QueryLogger(false); + $logger->add('A', null, microtime(true)); + $logger->enable(true); + $logger->add('B', null, microtime(true)); + + $queries = array_column($logger->all(), 'query'); + self::assertSame(['B'], $queries); + } + + public function test_clear_empties_the_buffer(): void + { + $logger = new QueryLogger(true); + $logger->add('SELECT 1', null, microtime(true)); + $logger->clear(); + + self::assertSame([], $logger->all()); + } + + public function test_isEnabled_reflects_constructor_argument(): void + { + self::assertFalse((new QueryLogger())->isEnabled()); + self::assertFalse((new QueryLogger(false))->isEnabled()); + self::assertTrue((new QueryLogger(true))->isEnabled()); + } + + public function test_add_with_null_start_time_records_zero_timer(): void + { + $logger = new QueryLogger(true); + $logger->add('SELECT 1', null, null); + + self::assertSame(0.0, $logger->all()[0]['timer']); + } +} diff --git a/tests/DataMapper/DataMapperFactoryTest.php b/tests/DataMapper/DataMapperFactoryTest.php new file mode 100644 index 0000000..b4480e4 --- /dev/null +++ b/tests/DataMapper/DataMapperFactoryTest.php @@ -0,0 +1,25 @@ +getPDO()->prepare('SELECT 1'); + + $mapper = (new DataMapperFactory())->createDataMapper($stmt); + + self::assertInstanceOf(DataMapperInterface::class, $mapper); + self::assertSame($stmt, $mapper->getStatement()); + } +} diff --git a/tests/DataMapper/DataMapperTest.php b/tests/DataMapper/DataMapperTest.php new file mode 100644 index 0000000..9983b57 --- /dev/null +++ b/tests/DataMapper/DataMapperTest.php @@ -0,0 +1,202 @@ +pdo = $connection->getPDO(); + } + + public function test_executes_a_statement(): void + { + $stmt = $this->pdo->prepare('SELECT id FROM users WHERE name = :name'); + $mapper = new DataMapper($stmt); + + self::assertTrue($mapper->bindValues(['name' => 'Alice'])); + self::assertTrue($mapper->execute()); + } + + public function test_bindValue_accepts_keys_with_or_without_colon(): void + { + $stmt = $this->pdo->prepare('SELECT id FROM users WHERE name = :name'); + $mapper = new DataMapper($stmt); + + $mapper->bindValue(':name', 'Alice'); + $mapper->execute(); + $row = $mapper->asAssoc()->row(); + self::assertSame(1, (int) $row['id']); + + $stmt2 = $this->pdo->prepare('SELECT id FROM users WHERE name = :name'); + $mapper2 = new DataMapper($stmt2); + $mapper2->bindValue('name', 'Alice'); + $mapper2->execute(); + self::assertSame(1, (int) $mapper2->asAssoc()->row()['id']); + } + + public function test_bind_chooses_correct_pdo_param_type(): void + { + $stmt = $this->pdo->prepare('SELECT 1'); + $mapper = new DataMapper($stmt); + + self::assertSame(PDO::PARAM_INT, $mapper->bind(7)); + self::assertSame(PDO::PARAM_BOOL, $mapper->bind(true)); // BUG-10 regression + self::assertSame(PDO::PARAM_BOOL, $mapper->bind(false)); + self::assertSame(PDO::PARAM_NULL, $mapper->bind(null)); + self::assertSame(PDO::PARAM_STR, $mapper->bind('abc')); + self::assertSame(PDO::PARAM_STR, $mapper->bind(1.5)); + } + + public function test_rows_returns_empty_array_when_no_match(): void + { + $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id'); + $mapper = new DataMapper($stmt); + $mapper->bindValue('id', 999); + $mapper->execute(); + + self::assertSame([], $mapper->rows()); // BUG-15 regression + } + + public function test_row_returns_null_when_no_row_remaining(): void + { + $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id'); + $mapper = new DataMapper($stmt); + $mapper->bindValue('id', 999); + $mapper->execute(); + + self::assertNull($mapper->row()); + } + + public function test_asAssoc_returns_associative_arrays(): void + { + $stmt = $this->pdo->prepare('SELECT id, name FROM users ORDER BY id'); + $mapper = new DataMapper($stmt); + $mapper->execute(); + + $rows = $mapper->asAssoc()->rows(); + self::assertSame('Alice', $rows[0]['name']); + self::assertArrayNotHasKey(0, $rows[0]); + } + + public function test_asObject_returns_stdClass_instances(): void + { + $stmt = $this->pdo->prepare('SELECT id, name FROM users ORDER BY id'); + $mapper = new DataMapper($stmt); + $mapper->execute(); + + $row = $mapper->asObject()->row(); + self::assertInstanceOf(stdClass::class, $row); + self::assertSame('Alice', $row->name); + } + + public function test_asObject_with_target_fills_existing_object(): void + { + $stmt = $this->pdo->prepare('SELECT id, name FROM users ORDER BY id'); + $mapper = new DataMapper($stmt); + $mapper->execute(); + + $target = new stdClass(); + $mapper->asObject($target); + $mapper->row(); + self::assertSame('Alice', $target->name); + } + + public function test_asClass_returns_named_class_instances(): void + { + $stmt = $this->pdo->prepare('SELECT id, name FROM users ORDER BY id'); + $mapper = new DataMapper($stmt); + $mapper->execute(); + + $row = $mapper->asClass(UserRow::class)->row(); + self::assertInstanceOf(UserRow::class, $row); + self::assertSame('Alice', $row->name); + } + + public function test_asArray_and_asBoth_are_equivalent(): void + { + $stmt = $this->pdo->prepare('SELECT id, name FROM users WHERE id = :id'); + $mapper = new DataMapper($stmt); + $mapper->bindValue('id', 1); + $mapper->execute(); + $row = $mapper->asBoth()->row(); + + self::assertArrayHasKey(0, $row); + self::assertArrayHasKey('name', $row); + } + + public function test_numRows_reports_affected_count_after_insert(): void + { + $stmt = $this->pdo->prepare("INSERT INTO users (name, email) VALUES ('Dan', 'dan@example.com')"); + $mapper = new DataMapper($stmt); + $mapper->execute(); + + self::assertSame(1, $mapper->numRows()); + } + + public function test_forwards_pdo_statement_methods_via_call(): void + { + $stmt = $this->pdo->prepare('SELECT name FROM users ORDER BY id'); + $mapper = new DataMapper($stmt); + $mapper->execute(); + + // closeCursor() returns true (PDOStatement, not $this) — should pass through. + self::assertTrue($mapper->closeCursor()); + } + + public function test_getQuery_returns_the_prepared_sql(): void + { + $sql = 'SELECT 1 AS one'; + $stmt = $this->pdo->prepare($sql); + $mapper = new DataMapper($stmt); + + self::assertSame($sql, $mapper->getQuery()); + } + + public function test_get_magic_forwards_to_statement_properties(): void + { + $stmt = $this->pdo->prepare('SELECT 1'); + $mapper = new DataMapper($stmt); + + self::assertSame('SELECT 1', $mapper->queryString); + } + + public function test_isset_magic_forwards_to_statement_properties(): void + { + $stmt = $this->pdo->prepare('SELECT 1'); + $mapper = new DataMapper($stmt); + + self::assertTrue(isset($mapper->queryString)); + } + + public function test_asLazy_sets_fetch_mode_to_lazy(): void + { + $stmt = $this->pdo->prepare('SELECT id, name FROM users WHERE id = :id'); + $mapper = new DataMapper($stmt); + $mapper->bindValue('id', 1); + $mapper->execute(); + $mapper->asLazy(); + + $row = $mapper->row(); + // PDORow exposes the columns as properties. + self::assertSame('Alice', $row->name); + } +} + +final class UserRow +{ + public int $id = 0; + public string $name = ''; +} diff --git a/tests/Support/SqliteHelper.php b/tests/Support/SqliteHelper.php new file mode 100644 index 0000000..ae109ff --- /dev/null +++ b/tests/Support/SqliteHelper.php @@ -0,0 +1,44 @@ + $overrides + */ + public static function makeConnection(array $overrides = []): Connection + { + $credentials = array_merge([ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'charset' => '', + ], $overrides); + + return new Connection($credentials); + } + + public static function seedUsers(Connection $connection): void + { + $pdo = $connection->getPDO(); + $pdo->exec('CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + score INTEGER, + metadata TEXT + )'); + $pdo->exec("INSERT INTO users (name, email, active, score, metadata) + VALUES ('Alice', 'alice@example.com', 1, 42, NULL), + ('Bob', 'bob@example.com', 0, 13, '{\"role\":\"admin\"}')"); + } +}