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
+
+[](https://github.com/InitORM/DBAL/actions/workflows/phpunit.yml)
+[](https://packagist.org/packages/initorm/dbal)
+[](https://packagist.org/packages/initorm/dbal)
+[](LICENSE)
+[](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\"}')");
+ }
+}