diff --git a/.github/workflows/composer-validate.yml b/.github/workflows/composer-validate.yml
new file mode 100644
index 0000000..6e558ed
--- /dev/null
+++ b/.github/workflows/composer-validate.yml
@@ -0,0 +1,29 @@
+name: Validate composer.json
+
+on:
+ push:
+ paths:
+ - 'composer.json'
+ pull_request:
+ paths:
+ - 'composer.json'
+
+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/phpcs.yml b/.github/workflows/phpcs.yml
new file mode 100644
index 0000000..da17e16
--- /dev/null
+++ b/.github/workflows/phpcs.yml
@@ -0,0 +1,40 @@
+name: PHP_CodeSniffer
+
+on:
+ push:
+ branches: ["master", "main", "v3.x", "v3.0.x"]
+ pull_request:
+ branches: ["master", "main", "v3.x", "v3.0.x"]
+
+permissions:
+ contents: read
+
+jobs:
+ phpcs:
+ name: PSR-12 lint
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.2"
+ coverage: none
+ tools: composer:v2
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer
+ key: composer-${{ runner.os }}-cs-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ composer-${{ runner.os }}-cs-
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-progress --no-interaction
+
+ - name: Run PHP_CodeSniffer
+ run: composer cs-ci
diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
new file mode 100644
index 0000000..2535613
--- /dev/null
+++ b/.github/workflows/phpstan.yml
@@ -0,0 +1,40 @@
+name: PHPStan
+
+on:
+ push:
+ branches: ["master", "main", "v3.x", "v3.0.x"]
+ pull_request:
+ branches: ["master", "main", "v3.x", "v3.0.x"]
+
+permissions:
+ contents: read
+
+jobs:
+ phpstan:
+ name: Static analysis (level 6)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.2"
+ coverage: none
+ tools: composer:v2
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer
+ key: composer-${{ runner.os }}-stan-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ composer-${{ runner.os }}-stan-
+
+ - name: Install dependencies
+ run: composer update --prefer-dist --no-progress --no-interaction
+
+ - name: Run PHPStan
+ run: composer stan
diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
new file mode 100644
index 0000000..a4eb63a
--- /dev/null
+++ b/.github/workflows/phpunit.yml
@@ -0,0 +1,62 @@
+name: PHPUnit
+
+on:
+ push:
+ branches: ["master", "main", "v3.x", "v3.0.x"]
+ pull_request:
+ branches: ["master", "main", "v3.x", "v3.0.x"]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: PHP ${{ matrix.php-version }}
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: ["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') }}
+ restore-keys: |
+ composer-${{ runner.os }}-${{ matrix.php-version }}-
+
+ - name: Install dependencies
+ run: composer update --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..bf270a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,11 @@
-/.idea/
-/.vs/
-/.vscode/
-/vendor/
-/composer.lock
-/.phpunit.result.cache
-/nbproject/private/
-/*.log
\ No newline at end of file
+/.idea/
+/.vs/
+/.vscode/
+/vendor/
+/composer.lock
+/.phpunit.result.cache
+/.phpunit.cache/
+/build/
+/coverage/
+/nbproject/private/
+/*.log
diff --git a/README.md b/README.md
index 284a387..e858b08 100644
--- a/README.md
+++ b/README.md
@@ -1,412 +1,362 @@
-# InitORM Database
-
-Manage your database with or without abstraction. This library is built on the PHP PDO plugin and is mainly used to build and execute SQL queries.
-
-[](https://packagist.org/packages/initorm/database) [](https://packagist.org/packages/initorm/database) [](https://packagist.org/packages/initorm/database) [](https://packagist.org/packages/initorm/database) [](https://packagist.org/packages/initorm/database)
-
-## Requirements
-
-- PHP 8.0 and later.
-- PHP PDO extension.
-
-## Supported Databases
-
-This library should work correctly in almost any database that uses basic SQL syntax.
-Databases supported by PDO and suitable drivers are available at [https://www.php.net/manual/en/pdo.drivers.php](https://www.php.net/manual/en/pdo.drivers.php).
-
-## Installation
-
-```
-composer require initorm/database
-```
-
-## Usage
-
-### QueryBuilder & DBAL and CRUD
-
-```php
-require_once "vendor/autoload.php";
-use \InitORM\Database\Facade\DB;
-
-// Connection
-DB::createImmutable([
- 'dsn' => 'mysql:host=localhost;port=3306;dbname=test;charset=utf8mb4',
- 'username' => 'root',
- 'password' => '',
- 'charset' => 'utf8mb4',
- 'collation' => 'utf8mb4_general_ci',
-]);
-```
-
-#### Create
-
-```php
-use \InitORM\Database\Facade\DB;
-$data = [
- 'title' => 'Post Title',
- 'content' => 'Post Content',
-];
-
-$isInsert = DB::create('post', $data);
-
-/**
-* This executes the following query.
-*
-* INSERT INTO post
-* (title, content)
-* VALUES
-* ("Post Title", "Post Content");
-*/
-if($isInsert){
- // Success
-} else {
- foreach (DB::getErrors() as $errMsg) {
- echo $errMsg;
- }
-}
-```
-
-##### Create Batch
-
-```php
-use \InitORM\Database\Facade\DB;
-
-$data = [
- [
- 'title' => 'Post Title 1',
- 'content' => 'Post Content 1',
- 'author' => 5
- ],
- [
- 'title' => 'Post Title 2',
- 'content' => 'Post Content 2'
- ],
-];
-
-$isInsert = DB::createBatch('post', $data);
-
-/**
-* This executes the following query.
-*
-* INSERT INTO post
-* (title, content, author)
-* VALUES
-* ("Post Title 1", "Post Content 1", 5),
-* ("Post Title 2", "Post Content 2", NULL);
-*/
-
-if($isInsert){
- // Success
-}
-```
-
-#### Read
-
-```php
-use \InitORM\Database\Facade\DB;
-
-
-/**
-* This executes the following query.
-*
-* SELECT user.name AS author_name, post.id, post.title
-* FROM post, user
-* WHERE user.id = post.author AND post.status = 1
-* ORDER BY post ASC, post.created_at DESC
-* LIMIT 20, 10
-*/
-
-$res = DB::select('user.name as author_name', 'post.id', 'post.title')
- ->from('post')
- ->selfJoin('user', 'user.id=post.author')
- ->where('post.status', 1)
- ->orderBy('post.id', 'ASC')
- ->orderBy('post.created_at', 'DESC')
- ->offset(20)->limit(10)
- ->read();
-
-if($res->numRows() > 0){
- $results = $res->asAssoc()
- ->rows();
- foreach ($results as $row) {
- echo $row['title'] . ' by ' . $row['author_name'] . '
';
- }
-}
-```
-
-#### Update
-
-```php
-use \InitORM\Database\Facade\DB;
-$data = [
- 'title' => 'New Title',
- 'content' => 'New Content',
-];
-
-$isUpdate = DB::where('id', 13)
- ->update('post', $data);
-
-/**
-* This executes the following query.
-*
-* UPDATE post
-* SET title = "New Title", content = "New Content"
-* WHERE id = 13
-*/
-if ($isUpdate) {
- // Success
-}
-```
-
-##### Update Batch
-
-```php
-use \InitORM\Database\Facade\DB;
-$data = [
- [
- 'id' => 5,
- 'title' => 'New Title #5',
- 'content' => 'New Content #5',
- ],
- [
- 'id' => 10,
- 'title' => 'New Title #10',
- ]
-];
-
-$isUpdate = DB::where('status', '!=', 0)
- ->updateBatch('id', 'post', $data);
-
-/**
-* This executes the following query.
-*
-* UPDATE post SET
-* title = CASE
-* WHEN id = 5 THEN 'New Title #5'
-* WHEN id = 10 THEN 'New Title #10'
-* ELSE title END,
-* content = CASE
-* WHEN id = 5 THEN 'New Content #5'
-* ELSE content END
-* WHERE status != 0 AND id IN (5, 10)
-*/
-if ($isUpdate) {
- // Success
-}
-```
-
-#### Delete
-
-```php
-use \InitORM\Database\Facade\DB;
-
-$isDelete = DB::where('id', 13)
- ->delete('post');
-
-/**
-* This executes the following query.
-*
-* DELETE FROM post WHERE id = 13
-*/
-if ($isUpdate) {
- // Success
-}
-```
-
-### RAW
-
-```php
-use \InitORM\Database\Facade\DB;
-
-$res = DB::query("SELECT id, title FROM post WHERE user_id = :id", [
- ':id' => 5
-]);
-
-if ($res->numRows() > 0) {
- $result = $res->asObject()
- ->row();
-
- echo $result->title;
-}
-```
-
-#### Builder for RAW
-
-```php
-use \InitORM\Database\Facade\DB;
-
-$res = DB::select(DB::raw("CONCAT(name, ' ', surname) AS fullname"))
- ->where(DB::raw("title = '' AND (status = 1 OR status = 0)"))
- ->limit(5)
- ->read('users');
-
-/**
- * SELECT CONCAT(name, ' ', surname) AS fullname
- * FROM users
- * WHERE title = '' AND (status = 1 OR status = 0)
- * LIMIT 5
- */
-$results = $res->asAssoc()
- ->rows();
-foreach ($results as $row) {
- echo $row['fullname'];
-}
-```
-
-#### Working With A Different Connection
-
-This library was developed with the thought that you would work with a single database and connection, but I know that in some projects you work with more than one connection and database.
-
-If you want to work with a different non-global connection, use the `connect()` method.
-
-```php
-use \InitORM\Database\Facade\DB;
-
-DB::connect([
- 'dsn' => 'mysql:host=localhost;port=3306;dbname=test;charset=utf8mb4',
- 'username' => 'root',
- 'password' => '',
- 'charset' => 'utf8mb4',
- 'collation' => 'utf8mb4_general_ci',
-]);
-```
-
-## Development Tools
-
-Below I have mentioned some developer tools that you can use during and after development.
-
-### Logger
-
-```php
-use \InitORM\Database\Facade\DB;
-
-DB::createImmutable([
- 'dsn' => 'mysql:host=localhost;dbname=test;port=3306;charset=utf8mb4;',
- 'username' => 'root',
- 'password' => '',
-
- 'log' => __DIR__ '/logs/db.log', // string, callable or object
-]);
-```
-
-If you define a file path as a String; Attempts are made to write into it with `file_put_contents()`.
-
-_Note :_ You can define variables such as `{year}`, `{month}`, `{day}` in the filename.
-
-- You can also define an object with the `critical` method. The database library will pass the log message to this method as a parameter. Or define it as callable array to use any method of the object.
-
-```php
-use \InitORM\Database\Facade\DB;
-
-class Logger {
-
- public function critical(string $msg)
- {
- $path = __DIR__ . '/log.log';
- file_put_contents($path, $msg, FILE_APPEND);
- }
-
-}
-
-$logger = new Logger();
-
-DB::createImmutable([
- 'dsn' => 'mysql:host=localhost;dbname=test;port=3306;charset=utf8mb4;',
- 'username' => 'root',
- 'password' => '',
-
- 'log' => $logger, // or [$logger, 'critical']
-]);
-```
-
-- Similarly it is possible to define it in a callable method.
-
-```php
-use \InitORM\Database\Facade\DB;
-
-DB::createImmutable([
- 'dsn' => 'mysql:host=localhost;dbname=test;port=3306;charset=utf8mb4;',
- 'username' => 'root',
- 'password' => '',
-
- 'log' => function (string $msg) {
- $path = __DIR__ . '/log.log';
- file_put_contents($path, $msg, FILE_APPEND);
- },
-]);
-```
-
-### DeBug Mode
-
-Debug mode is used to include the executed SQL statement in the error message. *__It should only be activated in the development environment__*.
-
-```php
-use \InitORM\Database\Facade\DB;
-
-DB::createImmutable([
- 'dsn' => 'mysql:host=localhost;dbname=test;port=3306;charset=utf8mb4;',
- 'username' => 'root',
- 'password' => '',
-
- 'debug' => true, // boolean
-]);
-```
-
-### Profiler Mode
-
-Profiler mode is a developer tool available in v3 and above. It is a feature that allows you to see the executed queries along with their execution times.
-
-```php
-use InitPHP\Database\Facade\DB;
-
-DB::enableQueryLog();
-
-DB::table('users')->where('name', 'John')->read();
-
-var_dump(DB::getQueryLogs());
-
-/**
- * The output of the above example looks like this;
- * [
- * [
- * 'query' => 'SELECT * FROM users WHERE name = :name',
- * 'time' => '0.00064',
- * 'args' => [
- * ':name' => 'John',
- * ]
- * ]
- * ]
- *
- */
-```
-
-## Getting Help
-
-If you have questions, concerns, bug reports, etc, please file an issue in this repository's Issue Tracker.
-
-## Getting Involved
-
-> All contributions to this project will be published under the MIT License. By submitting a pull request or filing a bug, issue, or feature request, you are agreeing to comply with this waiver of copyright interest.
-
-There are two primary ways to help:
-
-- Using the issue tracker, and
-- Changing the code-base.
-
-### Using the issue tracker
-
-Use the issue tracker to suggest feature requests, report bugs, and ask questions. This is also a great way to connect with the developers of the project as well as others who are interested in this solution.
-
-Use the issue tracker to find ways to contribute. Find a bug or a feature, mention in the issue that you will take on that effort, then follow the Changing the code-base guidance below.
-
-### Changing the code-base
-
-Generally speaking, you should fork this repository, make changes in your own fork, and then submit a pull request. All new code should have associated unit tests that validate implemented features and the presence or lack of defects. Additionally, the code should follow any stylistic and architectural guidelines prescribed by the project. In the absence of such guidelines, mimic the styles and patterns in the existing code-base.
-
-## Credits
-
-- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) <>
-
-## License
-
-Copyright © 2023 [MIT License](./LICENSE)
\ No newline at end of file
+# InitORM Database
+
+Composes [`initorm/dbal`](https://github.com/InitORM/DBAL) (PDO connection + result mapper) and [`initorm/query-builder`](https://github.com/InitORM/QueryBuilder) (fluent SQL builder) into a single Database manager with CRUD helpers, transactions, query logging, and an optional static facade.
+
+[](https://packagist.org/packages/initorm/database)
+[](https://packagist.org/packages/initorm/database)
+[](https://packagist.org/packages/initorm/database)
+[](https://packagist.org/packages/initorm/database)
+[](https://github.com/InitORM/Database/actions/workflows/phpunit.yml)
+[](https://github.com/InitORM/Database/actions/workflows/phpstan.yml)
+[](https://github.com/InitORM/Database/actions/workflows/phpcs.yml)
+
+---
+
+## Requirements
+
+- **PHP 8.1 or later**
+- `ext-pdo`
+- One of `ext-pdo_mysql`, `ext-pdo_pgsql`, or `ext-pdo_sqlite` depending on the database you target.
+
+## Supported databases
+
+Any database with a PDO driver that follows standard SQL works out of the box. The query builder ships dialect-aware identifier quoting for **MySQL/MariaDB**, **PostgreSQL**, and **SQLite**; for everything else (`oci`, `sqlsrv`, …) it falls back to a generic, no-escaping driver.
+
+## Installation
+
+```bash
+composer require initorm/database
+```
+
+---
+
+## Quick start
+
+```php
+ 'mysql:host=localhost;port=3306;dbname=app;charset=utf8mb4',
+ 'username' => 'app',
+ 'password' => 'secret',
+ 'charset' => 'utf8mb4',
+ 'collation' => 'utf8mb4_unicode_ci',
+]);
+
+// CRUD shortcut
+DB::create('users', ['name' => 'Alice', 'email' => 'alice@example.com']);
+
+// Fluent builder + CRUD
+$rows = DB::select('id', 'name')
+ ->where('active', '=', 1)
+ ->orderBy('id', 'DESC')
+ ->limit(10)
+ ->read('users')
+ ->asAssoc()
+ ->rows();
+```
+
+> `createImmutable()` sets the application-wide facade once. Calling it a second time throws — see [`docs/10-facade-vs-instance.md`](docs/10-facade-vs-instance.md) for swap and multi-connection patterns.
+
+---
+
+## Configuration reference
+
+All keys are passed through to the underlying [`InitORM\DBAL\Connection\Connection`](https://github.com/InitORM/DBAL/blob/master/src/Connection/Connection.php) constructor. Defaults are sane for MySQL.
+
+| Key | Type | Default | Notes |
+| -------------- | ------------------------------------ | ------------- | ---------------------------------------------------------------------------------------------- |
+| `dsn` | `string` | _(built)_ | When empty, a DSN is constructed from `driver`, `host`, `port`, `database`, `charset`. |
+| `driver` | `string` | `'mysql'` | `mysql`, `pgsql`/`postgres`/`postgresql`, `sqlite`, or any PDO driver name. |
+| `host` | `string` | `'127.0.0.1'` | Ignored when `dsn` is set explicitly. |
+| `port` | `int\|string` | `3306` | Ignored when `dsn` is set explicitly. |
+| `database` | `string` | `''` | For SQLite use `':memory:'` or a file path. |
+| `username` | `string\|null` | `null` | |
+| `password` | `string\|null` | `null` | |
+| `charset` | `string` | `'utf8mb4'` | Applied on MySQL via `SET NAMES`. Pass `''` to skip (e.g. SQLite). |
+| `collation` | `string\|null` | `null` | MySQL-only. Validated against `[A-Za-z0-9_]` before interpolation. |
+| `options` | `array` | `[]` | Merged on top of safe PDO defaults (exceptions on errors, FETCH_ASSOC, no emulation). |
+| `queryOptions` | `array` | `[]` | PDO `prepare()` options used for every statement. |
+| `log` | `string\|callable\|object\|null` | `null` | See [Logger](#logger). File path, callable, or any object with a `critical(string)` method. |
+| `debug` | `bool` | `false` | When true, query failure messages also include the bound parameters (JSON-encoded). |
+| `queryLogs` | `bool` | `false` | Bootstrap value for the query log buffer (see [Query log](#query-log)). |
+
+---
+
+## CRUD
+
+All CRUD helpers reset the builder's state on completion, so the next call starts with a clean slate. Every helper returns `bool true` on successful execution and throws on failure — use [`affectedRows()`](#affected-rows) when you also need to know how many rows changed.
+
+### Create
+
+```php
+use InitORM\Database\Facade\DB;
+
+DB::create('posts', [
+ 'title' => 'Post Title',
+ 'content' => 'Post Content',
+]);
+
+$newId = DB::insertId();
+```
+
+Generated SQL: `INSERT INTO posts (title, content) VALUES (:title, :content)`
+
+### Create batch
+
+```php
+DB::createBatch('posts', [
+ ['title' => 'Post #1', 'content' => 'Body 1', 'author_id' => 5],
+ ['title' => 'Post #2', 'content' => 'Body 2'],
+]);
+```
+
+Generated SQL: `INSERT INTO posts (title, content, author_id) VALUES (:title, :content, :author_id), (:title_1, :content_1, NULL)`
+
+Missing columns in any row compile to `NULL`.
+
+### Read
+
+```php
+$res = DB::select('user.name AS author_name', 'post.id', 'post.title')
+ ->from('post')
+ ->selfJoin('user', 'user.id=post.author')
+ ->where('post.status', '=', 1)
+ ->orderBy('post.id', 'ASC')
+ ->orderBy('post.created_at', 'DESC')
+ ->offset(20)
+ ->limit(10)
+ ->read();
+
+foreach ($res->asAssoc()->rows() as $row) {
+ echo $row['title'] . ' by ' . $row['author_name'] . PHP_EOL;
+}
+```
+
+### Update
+
+```php
+DB::update('post', ['title' => 'New Title', 'content' => 'New Content'], ['id' => 13]);
+```
+
+Generated SQL: `UPDATE post SET title = :title, content = :content WHERE id = :id`
+
+### Update batch
+
+```php
+DB::where('status', '!=', 0)
+ ->updateBatch('id', 'post', [
+ ['id' => 5, 'title' => 'New Title #5', 'content' => 'New Content #5'],
+ ['id' => 10, 'title' => 'New Title #10'],
+ ]);
+```
+
+Generated SQL (formatted):
+
+```sql
+UPDATE post SET
+ title = CASE WHEN id = :id THEN :title WHEN id = :id_1 THEN :title_1 ELSE title END,
+ content = CASE WHEN id = :id_2 THEN :content ELSE content END
+WHERE status != :status AND id IN (:id_3, :id_4)
+```
+
+### Delete
+
+```php
+DB::delete('post', ['id' => 13]);
+```
+
+Generated SQL: `DELETE FROM post WHERE id = :id`
+
+### Affected rows
+
+```php
+DB::update('users', ['active' => 0], ['active' => 1]);
+echo DB::affectedRows(); // e.g. 42
+```
+
+`affectedRows()` returns the row count of the most recent CRUD call on the same Database instance.
+
+---
+
+## Raw queries
+
+```php
+$res = DB::query(
+ 'SELECT id, title FROM post WHERE user_id = :id',
+ [':id' => 5]
+);
+
+if ($res->numRows() > 0) {
+ $result = $res->asObject()->row();
+ echo $result->title;
+}
+```
+
+You can also use `DB::raw()` inside the builder to inject literal SQL fragments — **never embed unsanitized user input**:
+
+```php
+$res = DB::select(DB::raw("CONCAT(name, ' ', surname) AS fullname"))
+ ->where(DB::raw('status = 1 OR status = 0'))
+ ->limit(5)
+ ->read('users');
+```
+
+---
+
+## Transactions
+
+```php
+DB::transaction(function (\InitORM\Database\Interfaces\DatabaseInterface $db) {
+ $db->create('orders', ['user_id' => 5, 'total' => 199.90]);
+ $db->create('order_items',['order_id' => $db->insertId(), 'sku' => 'X', 'qty' => 1]);
+});
+```
+
+- The closure receives the Database instance.
+- Throw to abort: the current transaction is rolled back; if `$attempt > 1` the closure is retried; otherwise the original error is rethrown wrapped in a `DatabaseException` (the original is reachable via `$e->getPrevious()`).
+- Pass `testMode: true` to roll back even on success — useful for integration tests.
+
+```php
+$caught = null;
+try {
+ DB::transaction(function ($db) {
+ $db->create('orders', [...]);
+ throw new \RuntimeException('boom');
+ });
+} catch (\InitORM\Database\Exceptions\DatabaseException $e) {
+ $caught = $e->getPrevious(); // \RuntimeException 'boom'
+}
+```
+
+---
+
+## Multiple connections
+
+`DB::createImmutable()` registers a single shared facade. For secondary connections, use `DB::connect()` or instantiate `Database` directly — these do not touch the facade slot.
+
+```php
+use InitORM\Database\Database;
+
+$reports = new Database([
+ 'dsn' => 'pgsql:host=reports.internal;dbname=reports',
+ 'username' => 'reports_ro',
+ 'password' => '…',
+ 'driver' => 'pgsql',
+]);
+
+$reports->read('events')->asAssoc()->rows();
+```
+
+If you must swap the immutable facade target (rare; mostly for tests), call `DB::replaceImmutable($next)` explicitly — silent overrides are forbidden.
+
+---
+
+## Developer tools
+
+### Logger
+
+The `log` credential accepts three shapes — a file path, a callable, or any object with a `critical(string)` method. The DBAL Logger writes a single string message per failed query, prefixed with the SQL and (when `debug` is on) the bound parameters.
+
+```php
+// 1) File path — file_put_contents() with append
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'log' => __DIR__ . '/var/log/db-{year}-{month}-{day}.log',
+]);
+
+// 2) Callable
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'log' => function (string $msg): void {
+ error_log($msg);
+ },
+]);
+
+// 3) Object with critical() (or a [$obj, 'method'] callable)
+class Logger {
+ public function critical(string $msg): void { /* … */ }
+}
+
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'log' => new Logger(),
+]);
+```
+
+### Debug mode
+
+```php
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'debug' => true, // include bound parameters in failure messages
+]);
+```
+
+> Enable in development only — bound parameter dumps can include credentials and PII.
+
+### Query log
+
+```php
+DB::enableQueryLog();
+DB::read('users', ['id', 'name'], ['active' => 1]);
+
+var_dump(DB::getQueryLogs());
+/*
+[
+ [
+ 'query' => 'SELECT id, name FROM users WHERE active = :active',
+ 'args' => [':active' => 1],
+ 'timer' => 0.000642,
+ ],
+]
+*/
+```
+
+`enableQueryLog()` / `disableQueryLog()` return the Database instance for chaining; `getQueryLogs()` returns every recorded entry. The buffer lives on the Connection — calling `disableQueryLog()` stops recording but does not clear previously-collected entries.
+
+---
+
+## Documentation
+
+In-depth, code-first guides live under [`docs/`](docs/):
+
+- [`01-getting-started.md`](docs/01-getting-started.md)
+- [`02-configuration.md`](docs/02-configuration.md)
+- [`03-crud.md`](docs/03-crud.md)
+- [`04-query-builder.md`](docs/04-query-builder.md)
+- [`05-transactions.md`](docs/05-transactions.md)
+- [`06-raw-queries.md`](docs/06-raw-queries.md)
+- [`07-multiple-connections.md`](docs/07-multiple-connections.md)
+- [`08-logger-and-debug.md`](docs/08-logger-and-debug.md)
+- [`09-query-log-profiler.md`](docs/09-query-log-profiler.md)
+- [`10-facade-vs-instance.md`](docs/10-facade-vs-instance.md)
+- [`11-architecture.md`](docs/11-architecture.md)
+- [`12-upgrade-guide.md`](docs/12-upgrade-guide.md) — **migrating from v2 to v3**
+
+---
+
+## Contributing
+
+Contributions are welcome. The general flow is:
+
+1. Fork and branch off `master`.
+2. Add tests for the behaviour you change — see [`tests/`](tests/) for patterns (SQLite in-memory, fast and dependency-free).
+3. Run the full quality suite locally:
+ ```bash
+ composer qa # phpcs + phpstan + phpunit
+ ```
+4. Open a PR — CI will run the same suite across PHP 8.1–8.4.
+
+By submitting a contribution you agree to license it under the MIT License.
+
+## Credits
+
+- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) — ``
+
+## License
+
+Released under the [MIT License](./LICENSE).
diff --git a/composer.json b/composer.json
index 0b842ac..9aef663 100644
--- a/composer.json
+++ b/composer.json
@@ -1,25 +1,76 @@
{
"name": "initorm/database",
- "description": "InitORM Database Manager",
- "keywords": ["php", "php8", "pdo", "database", "crud", "query builder", "mysql", "postgresql", "sqlite"],
+ "description": "Database manager that composes initorm/dbal and initorm/query-builder into a fluent CRUD + transaction + query-log API with an optional static facade.",
"type": "library",
"license": "MIT",
- "autoload": {
- "psr-4": {
- "InitORM\\Database\\": "src/"
- }
+ "keywords": [
+ "database",
+ "crud",
+ "transaction",
+ "query-builder",
+ "dbal",
+ "pdo",
+ "mysql",
+ "pgsql",
+ "sqlite",
+ "initorm"
+ ],
+ "homepage": "https://github.com/InitORM/Database",
+ "support": {
+ "issues": "https://github.com/InitORM/Database/issues",
+ "source": "https://github.com/InitORM/Database",
+ "docs": "https://github.com/InitORM/Database/tree/master/docs"
},
"authors": [
{
"name": "Muhammet ŞAFAK",
- "email": "info@muhammetsafak.com.tr"
+ "email": "info@muhammetsafak.com.tr",
+ "homepage": "https://www.muhammetsafak.com.tr",
+ "role": "Developer"
}
],
- "minimum-stability": "stable",
"require": {
- "php": ">=7.4",
+ "php": "^8.1",
"ext-pdo": "*",
- "initorm/dbal": "^1.0",
- "initorm/query-builder": "^1.0"
+ "initorm/dbal": "^2.0",
+ "initorm/query-builder": "^2.0"
+ },
+ "require-dev": {
+ "ext-pdo_sqlite": "*",
+ "phpunit/phpunit": "^10.5",
+ "squizlabs/php_codesniffer": "^3.10",
+ "phpstan/phpstan": "^1.12"
+ },
+ "suggest": {
+ "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\\Database\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "InitORM\\Database\\Tests\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "phpunit",
+ "test:coverage": "phpunit --coverage-text --coverage-html=build/coverage",
+ "cs": "phpcs",
+ "cs-ci": "phpcs --warning-severity=0",
+ "cs-fix": "phpcbf",
+ "stan": "phpstan analyse",
+ "qa": [
+ "@cs-ci",
+ "@stan",
+ "@test"
+ ]
+ },
+ "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..f4f7c58
--- /dev/null
+++ b/docs/01-getting-started.md
@@ -0,0 +1,80 @@
+# Getting started
+
+This page walks through the smallest possible "hello, database" with `initorm/database` — install, connect, run one query.
+
+## Install
+
+```bash
+composer require initorm/database
+```
+
+The package requires **PHP 8.1+** and `ext-pdo`. You also need the PDO driver for your database (`ext-pdo_mysql`, `ext-pdo_pgsql`, or `ext-pdo_sqlite`).
+
+## Choose a usage style
+
+There are two equally first-class ways to use this library:
+
+- **Static facade (`DB::…`)** — one shared connection per application. Convenient for scripts, CLI tools, and applications that don't use DI containers.
+- **Instance API (`new Database(…)`)** — explicit dependency injection. Recommended in larger applications, libraries, and anywhere you need more than one connection.
+
+Pick whichever fits. See [Facade vs Instance](10-facade-vs-instance.md) for the trade-offs.
+
+## First connection — facade
+
+```php
+ 'sqlite::memory:',
+ 'driver' => 'sqlite',
+ 'charset' => '',
+]);
+
+DB::query('CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT)');
+
+DB::create('notes', ['body' => 'Hello, InitORM!']);
+
+$rows = DB::read('notes')->asAssoc()->rows();
+print_r($rows);
+// [ ['id' => '1', 'body' => 'Hello, InitORM!'] ]
+```
+
+## First connection — instance
+
+```php
+ 'sqlite',
+ 'database' => ':memory:',
+ 'charset' => '',
+]);
+
+$db->query('CREATE TABLE notes (id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT)');
+$db->create('notes', ['body' => 'Hello, InitORM!']);
+
+$rows = $db->read('notes')->asAssoc()->rows();
+```
+
+## What you get
+
+A `Database` instance composes:
+
+- A **Connection** from `initorm/dbal` (PDO lifecycle, prepared statements, result mapping).
+- A **QueryBuilder** from `initorm/query-builder` (fluent SQL assembly, parameter binding, dialect quoting).
+
+The full surface of both is reachable through the Database — calls flow:
+
+```
+$db->select(...)->where(...)->read(...)
+ └─ select/where forwarded to the inner builder
+ read() compiles the builder's SQL and executes it through the connection
+```
+
+Continue with [Configuration](02-configuration.md) for the full credential reference.
diff --git a/docs/02-configuration.md b/docs/02-configuration.md
new file mode 100644
index 0000000..ed441fe
--- /dev/null
+++ b/docs/02-configuration.md
@@ -0,0 +1,107 @@
+# Configuration
+
+A Database is configured through a single credentials array, passed either to `DB::createImmutable()` or to `new Database(…)`. The keys are forwarded as-is to the underlying [`InitORM\DBAL\Connection\Connection`](https://github.com/InitORM/DBAL/blob/master/src/Connection/Connection.php).
+
+## Full reference
+
+| Key | Type | Default | What it does |
+| -------------- | ------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `dsn` | `string` | _(built)_ | The PDO DSN. When empty, one is constructed from `driver`/`host`/`port`/`database`/`charset` via DBAL's `DsnBuilder`. |
+| `driver` | `string` | `'mysql'` | `mysql`, `pgsql` / `postgres` / `postgresql`, `sqlite`, or any PDO driver name (`oci`, `sqlsrv`, …). Also picks the QueryBuilder dialect (identifier quoting). |
+| `host` | `string` | `'127.0.0.1'` | Ignored when `dsn` is provided. |
+| `port` | `int\|string` | `3306` | Ignored when `dsn` is provided. |
+| `database` | `string` | `''` | For SQLite use `':memory:'` or a file path. Ignored when `dsn` is provided. |
+| `username` | `string\|null` | `null` | |
+| `password` | `string\|null` | `null` | |
+| `charset` | `string` | `'utf8mb4'` | MySQL only: applied via `SET NAMES`. Pass `''` to skip (SQLite, PostgreSQL). |
+| `collation` | `string\|null` | `null` | MySQL only. Validated against `[A-Za-z0-9_]` before interpolation into `COLLATE`. |
+| `options` | `array` | `[]` | Merged on top of safe defaults: `ATTR_EMULATE_PREPARES=false`, `ATTR_PERSISTENT=false`, `ATTR_ERRMODE=ERRMODE_EXCEPTION`, `ATTR_DEFAULT_FETCH_MODE=FETCH_ASSOC`. |
+| `queryOptions` | `array` | `[]` | PDO `prepare()` options used for every prepared statement. |
+| `log` | `string\|callable\|object\|null` | `null` | See [Logger](08-logger-and-debug.md). Accepts a file path with `{year}/{month}/{day}` placeholders, a callable `function(string $msg)`, or any object with a `critical(string $msg)` method (PSR-3 LoggerInterface fits this contract). |
+| `debug` | `bool` | `false` | When true, query failure messages also include the bound parameters (JSON-encoded). **Enable in development only**. |
+| `queryLogs` | `bool` | `false` | Bootstrap value for the query log buffer. You can flip this at runtime with `enableQueryLog()` / `disableQueryLog()`. |
+
+## DSN: explicit vs auto-built
+
+You can either supply a full DSN…
+
+```php
+new Database([
+ 'dsn' => 'mysql:host=localhost;port=3306;dbname=app;charset=utf8mb4',
+ 'username' => 'app',
+ 'password' => 'secret',
+]);
+```
+
+…or let DBAL assemble one from parts:
+
+```php
+new Database([
+ 'driver' => 'mysql',
+ 'host' => 'db.internal',
+ 'port' => 3306,
+ 'database' => 'app',
+ 'charset' => 'utf8mb4',
+ 'username' => 'app',
+ 'password' => 'secret',
+]);
+```
+
+DBAL keys with `charset` only when the driver expects it (MySQL).
+
+## Driver examples
+
+### MySQL / MariaDB
+
+```php
+[
+ 'dsn' => 'mysql:host=localhost;port=3306;dbname=app;charset=utf8mb4',
+ 'username' => 'app',
+ 'password' => '…',
+ 'charset' => 'utf8mb4',
+ 'collation' => 'utf8mb4_unicode_ci',
+]
+```
+
+### PostgreSQL
+
+```php
+[
+ 'dsn' => 'pgsql:host=localhost;port=5432;dbname=app',
+ 'username' => 'app',
+ 'password' => '…',
+ 'driver' => 'pgsql',
+ 'charset' => '', // not applicable
+]
+```
+
+### SQLite
+
+```php
+[
+ 'driver' => 'sqlite',
+ 'database' => __DIR__ . '/var/app.sqlite', // or ':memory:'
+ 'charset' => '',
+]
+```
+
+## Safe defaults you get for free
+
+The default PDO options enable exception mode, disable emulated prepares (so prepared statements actually use server-side prepares on MySQL), keep connections non-persistent, and default the fetch mode to associative arrays:
+
+```php
+[
+ PDO::ATTR_EMULATE_PREPARES => false,
+ PDO::ATTR_PERSISTENT => false,
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+]
+```
+
+Override any of them by passing your own `options` key — your values are merged on top, not replaced.
+
+## Switching credentials after construction
+
+Most setters on the underlying Connection (e.g. `setDatabase()`, `setHost()`) throw if PDO has already opened a connection — by design, since changing them silently would leak. Build a fresh Connection instead, or call `disconnect()` first.
+
+Continue with [CRUD operations](03-crud.md).
diff --git a/docs/03-crud.md b/docs/03-crud.md
new file mode 100644
index 0000000..7d57394
--- /dev/null
+++ b/docs/03-crud.md
@@ -0,0 +1,171 @@
+# CRUD operations
+
+The Database surface exposes five CRUD helpers plus their batch variants. They share a few common rules:
+
+- **The builder is the source of truth.** Anything you chain (`where()`, `select()`, `orderBy()`, …) is folded into the compiled SQL.
+- **State resets after every CRUD call.** The structure and parameter bag are wiped in a `finally` block, so the next call starts clean — even if execution threw.
+- **Return value is `bool true` on success.** Failures throw (`SQLExecuteException`, `ConnectionException`, `QueryBuilderException`). For affected-row counts, use [`affectedRows()`](#affected-rows).
+- **Parameters are auto-bound.** You never concatenate user values into SQL; the builder registers them as `:placeholder` and binds via PDO.
+
+All examples below assume the SQLite-in-memory setup from [Getting started](01-getting-started.md).
+
+## Create
+
+```php
+DB::create('users', [
+ 'name' => 'Alice',
+ 'email' => 'alice@example.com',
+ 'active' => 1,
+]);
+
+// Auto-incremented PK
+echo DB::insertId(); // "1"
+```
+
+Generated SQL: `INSERT INTO users (name, email, active) VALUES (:name, :email, :active)`
+
+`create()` accepts either:
+
+```php
+DB::create('users', $data); // table + set
+DB::from('users')->set($data)->create(); // builder-first
+```
+
+Both produce identical SQL.
+
+## Create batch
+
+```php
+DB::createBatch('users', [
+ ['name' => 'B', 'email' => 'b@example.com', 'active' => 1],
+ ['name' => 'C', 'email' => 'c@example.com', 'active' => 0],
+ ['name' => 'D', 'email' => 'd@example.com'], // missing 'active' → NULL
+]);
+```
+
+Generated SQL (one statement, multi-row):
+
+```sql
+INSERT INTO users (name, email, active) VALUES
+ (:name, :email, :active),
+ (:name_1, :email_1, :active_1),
+ (:name_2, :email_2, NULL)
+```
+
+Missing columns in any row compile to the literal `NULL` — the column union is collected across all rows.
+
+## Read
+
+```php
+$result = DB::read('users', ['id', 'name', 'email'], ['active' => 1]);
+
+foreach ($result->asAssoc()->rows() as $row) {
+ printf("#%d %s <%s>\n", $row['id'], $row['name'], $row['email']);
+}
+```
+
+Generated SQL: `SELECT id, name, email FROM users WHERE active = :active`
+
+`read()` returns a `DataMapperInterface`. The most useful methods on it:
+
+- `asAssoc() / asObject() / asClass(StdClass::class) / asLazy()` — fetch mode
+- `row()` — fetch the next row (`array|object|null`)
+- `rows()` — fetch all remaining rows
+- `numRows()` — `rowCount()` of the underlying statement
+
+Mix the builder freely:
+
+```php
+DB::select('id', 'name')
+ ->from('users')
+ ->whereIn('id', [1, 2, 3])
+ ->orderBy('name', 'ASC')
+ ->limit(10)
+ ->read()
+ ->asAssoc()
+ ->rows();
+```
+
+## Update
+
+```php
+DB::update('users', ['active' => 0], ['id' => 1]);
+```
+
+Generated SQL: `UPDATE users SET active = :active WHERE id = :id`
+
+Or builder-first:
+
+```php
+DB::where('email', 'LIKE', '%@example.com')
+ ->update('users', ['active' => 0]);
+```
+
+## Update batch (CASE/WHEN per row)
+
+```php
+DB::where('status', '!=', 0)
+ ->updateBatch('id', 'users', [
+ ['id' => 1, 'score' => 100],
+ ['id' => 2, 'score' => 200],
+ ]);
+```
+
+Generated SQL (formatted):
+
+```sql
+UPDATE users SET
+ score = CASE
+ WHEN id = :id THEN :score
+ WHEN id = :id_1 THEN :score_1
+ ELSE score
+ END
+WHERE status != :status AND id IN (:id_2, :id_3)
+```
+
+The first argument is the **reference column** — every row in `$set` must contain it. The compiler folds the row IDs into an automatic `WHERE … IN (…)` clause so unrelated rows aren't touched.
+
+## Delete
+
+```php
+DB::delete('users', ['id' => 2]);
+```
+
+Generated SQL: `DELETE FROM users WHERE id = :id`
+
+A delete without `WHERE` is allowed but discouraged; the compiler falls back to `WHERE 1` for clarity:
+
+```php
+DB::delete('users');
+// DELETE FROM users WHERE 1
+```
+
+## Affected rows
+
+```php
+DB::update('users', ['active' => 0], ['active' => 1]);
+echo DB::affectedRows(); // e.g. 42
+```
+
+`affectedRows()` returns the row count of the most recent CRUD call on the same Database. It's `0` when no CRUD call has executed yet and reliable for INSERT/UPDATE/DELETE on common drivers. For SELECT it depends on whether the driver buffers results — use `numRows()` on the returned DataMapper when you need a hard guarantee.
+
+## Conditions shortcut shape
+
+The `$conditions` parameter on `read` / `update` / `updateBatch` / `delete` accepts a mixed-key array:
+
+| Key type | Effect |
+| -------- | ------------------------------------------------------------------------ |
+| String | `where(key, '=', value)` — equality comparison |
+| Integer | `where(value)` — `value` is a `RawQuery` or a literal column expression |
+
+```php
+// String keys
+DB::read('users', null, ['active' => 1, 'role' => 'admin']);
+// WHERE active = :active AND role = :role
+
+// Integer key with RawQuery
+DB::read('users', null, [DB::raw('score > 50')]);
+// WHERE score > 50
+```
+
+Continue with [the query builder surface](04-query-builder.md).
diff --git a/docs/04-query-builder.md b/docs/04-query-builder.md
new file mode 100644
index 0000000..4f361db
--- /dev/null
+++ b/docs/04-query-builder.md
@@ -0,0 +1,166 @@
+# Query builder surface
+
+Every method on `InitORM\QueryBuilder\QueryBuilderInterface` is reachable directly through a Database instance via `__call`. Calls that return the builder are re-wrapped to return the Database, so fluent chains span the wrapper boundary:
+
+```php
+$db->select(...)->where(...)->orderBy(...)->read('users');
+// ^ all of those return $db (not the builder), so .read() works.
+```
+
+This page is a quick tour of the surface. For the authoritative reference, see the [QueryBuilder package docs](https://github.com/InitORM/QueryBuilder).
+
+## SELECT projection
+
+```php
+$db->select('id', 'name', $db->raw('NOW() AS now'));
+$db->selectCount('id', 'total');
+$db->selectMax('score', 'max_score');
+$db->selectAs('name', 'username');
+$db->selectConcat(['first_name', $db->raw("' '"), 'last_name'], 'full_name');
+```
+
+Helpers exist for `Count`, `CountDistinct`, `Max`, `Min`, `Avg`, `Sum`, `Upper`, `Lower`, `Length`, `Mid`, `Left`, `Right`, `Distinct`, `Coalesce`, `As`, `Concat`.
+
+## FROM / table
+
+```php
+$db->from('users'); // FROM users
+$db->from('users', 'u'); // FROM users AS u
+$db->addFrom('roles', 'r'); // FROM users AS u, roles AS r
+$db->table('users'); // FROM users (alias-less)
+```
+
+`from()` resets the table list; `addFrom()` appends.
+
+## JOIN
+
+```php
+$db->join('roles', 'roles.user_id = users.id', 'LEFT');
+$db->innerJoin('roles', 'roles.user_id = users.id');
+$db->leftJoin('roles', 'roles.user_id = users.id');
+$db->rightJoin('roles', 'roles.user_id = users.id');
+$db->leftOuterJoin('roles', 'roles.user_id = users.id');
+$db->rightOuterJoin('roles', 'roles.user_id = users.id');
+$db->selfJoin('roles', 'roles.user_id = users.id'); // comma-FROM with ON folded into WHERE
+$db->naturalJoin('roles'); // no ON clause
+```
+
+The `$onStmt` argument can be a string, a `RawQuery`, or a `Closure` that receives a fresh builder for composing complex ON expressions.
+
+## WHERE / HAVING / ON
+
+The basic shape is `where(column, operator, value, logical = 'AND')`:
+
+```php
+$db->where('id', '=', 5);
+$db->where('age', '>', 18);
+$db->where('email', 'LIKE', '%@example.com');
+
+// 2-arg shortcut: operator defaults to '='
+$db->where('id', 5);
+
+// 1-arg form: column is a RawQuery / literal expression
+$db->where($db->raw('score > 50'));
+
+// Logical operator
+$db->where('a', '=', 1)->where('b', '=', 2, 'OR');
+$db->andWhere('c', '=', 3);
+$db->orWhere('d', '=', 4);
+```
+
+### IS NULL / IS NOT NULL
+
+```php
+$db->whereIsNull('deleted_at');
+$db->whereIsNotNull('email');
+$db->andWhereIsNull('locked_at');
+$db->orWhereIsNotNull('verified_at');
+```
+
+### IN / NOT IN
+
+```php
+$db->whereIn('id', [1, 2, 3]);
+$db->whereNotIn('id', [4, 5]);
+$db->orWhereIn('role', ['admin', 'editor']);
+```
+
+Numeric elements are inlined verbatim; strings are parameterized. A `RawQuery` (sub-query) is rendered as-is.
+
+### BETWEEN / NOT BETWEEN
+
+```php
+$db->between('age', 18, 65);
+$db->between('age', [18, 65]); // also accepted
+$db->notBetween('score', 0, 50);
+```
+
+### LIKE family
+
+```php
+$db->like('name', 'john', 'both'); // LIKE '%john%'
+$db->like('name', 'john', 'start'); // LIKE 'john%'
+$db->like('name', 'john', 'end'); // LIKE '%john'
+
+$db->startLike('email', 'admin'); // LIKE 'admin%'
+$db->endLike('email', '.com'); // LIKE '%.com'
+$db->notLike('name', 'spam'); // NOT LIKE '%spam%'
+```
+
+All forms have `or*` / `and*` siblings (`orLike`, `andStartLike`, …).
+
+### Sub-queries and groups
+
+```php
+$db->whereIn('user_id', $db->subQuery(function ($qb) {
+ $qb->select('id')->from('users')->where('active', '=', 1);
+}));
+// WHERE user_id IN (SELECT id FROM users WHERE active = :active)
+
+$db->group(function ($qb) {
+ $qb->where('a', '=', 1)->orWhere('b', '=', 2);
+});
+// WHERE (a = :a OR b = :b)
+```
+
+## GROUP BY / HAVING / ORDER BY / LIMIT / OFFSET
+
+```php
+$db->select('role', $db->raw('COUNT(*) AS n'))
+ ->from('users')
+ ->groupBy('role')
+ ->having('n', '>', 10)
+ ->orderBy('n', 'DESC')
+ ->limit(5)
+ ->offset(10)
+ ->read();
+```
+
+## INSERT / UPDATE shape (`set()`)
+
+```php
+$db->from('users')->set(['name' => 'Eve', 'active' => 1])->create();
+$db->from('users')->set('name', 'Eve')->set('active', 1)->create();
+```
+
+Both forms produce the same INSERT. Repeated `set([...])` calls on `createBatch()` / `updateBatch()` produce multi-row shapes.
+
+## Inspecting the compiled SQL
+
+```php
+$db->select('id')->from('users')->where('active', '=', 1);
+
+// Generate SQL without executing:
+$sql = $db->getParameter()->all()
+ + ['__sql' => $db->generateSelectQuery()];
+
+// Or call directly through the chain (Database forwards to the builder)
+echo $db->generateSelectQuery();
+// SELECT id FROM users WHERE active = :active
+print_r($db->getParameter()->all());
+// [':active' => 1]
+```
+
+For more, the full set of compile methods (`generateInsertQuery`, `generateBatchInsertQuery`, `generateUpdateQuery`, `generateUpdateBatchQuery`, `generateDeleteQuery`) is available through `__call`.
+
+Continue with [Transactions](05-transactions.md).
diff --git a/docs/05-transactions.md b/docs/05-transactions.md
new file mode 100644
index 0000000..0bd4a77
--- /dev/null
+++ b/docs/05-transactions.md
@@ -0,0 +1,109 @@
+# Transactions
+
+The Database exposes a single high-level transaction entry point:
+
+```php
+$db->transaction(Closure $closure, int $attempt = 1, bool $testMode = false): bool;
+```
+
+The closure receives the Database instance as its only argument. The transaction is committed when the closure returns; it is rolled back when the closure throws.
+
+## Commit on success
+
+```php
+$db->transaction(function ($db) {
+ $orderId = $db->create('orders', ['user_id' => 5, 'total' => 199.90])
+ ? $db->insertId()
+ : null;
+
+ $db->create('order_items', [
+ 'order_id' => $orderId,
+ 'sku' => 'X-1',
+ 'qty' => 1,
+ ]);
+});
+```
+
+If both inserts succeed, the transaction commits and the helper returns `true`.
+
+## Rollback on exception
+
+When the closure throws, the current transaction is rolled back and the original throwable is wrapped in `DatabaseException`:
+
+```php
+use InitORM\Database\Exceptions\DatabaseException;
+
+try {
+ $db->transaction(function ($db) {
+ $db->create('orders', [...]);
+ throw new \RuntimeException('payment declined');
+ });
+} catch (DatabaseException $e) {
+ // $e->getMessage() => "Transaction failed after 1 attempt(s): payment declined"
+ // $e->getPrevious() => the original RuntimeException
+}
+```
+
+This was a regression in earlier versions: failures used to be swallowed and the caller got back a bare `false`. Now you always get the original error — wrapped, but reachable through `getPrevious()`.
+
+## Retry on transient failures
+
+Set `$attempt > 1` to retry the closure on failure (each attempt opens a fresh transaction):
+
+```php
+$db->transaction(function ($db) {
+ // Some operation that might deadlock under contention.
+ $db->update('counters', ['value' => $db->raw('value + 1')], ['id' => 1]);
+}, attempt: 5);
+```
+
+If any attempt succeeds, the helper returns `true`. If all attempts fail, the **last** error is wrapped and re-thrown.
+
+`attempt < 1` throws `DatabaseInvalidArgumentException` — silently doing nothing is worse than a loud failure.
+
+## testMode: roll back even on success
+
+Pass `testMode: true` to always roll back. Useful for integration tests that want to exercise the closure without persisting changes:
+
+```php
+$db->transaction(function ($db) {
+ $db->create('users', ['name' => 'TEMP', 'email' => 't@example.com', 'active' => 1]);
+}, testMode: true);
+
+// The INSERT was rolled back — table is unchanged.
+```
+
+## Nested transactions
+
+PDO does not natively support nested transactions, so attempting to start one inside another throws:
+
+```php
+$db->transaction(function ($db) {
+ $db->transaction(function ($db) {
+ // Throws: "Cannot start a transaction while another is already in progress."
+ });
+});
+```
+
+If you need savepoints, drop down to raw SQL:
+
+```php
+$db->transaction(function ($db) {
+ $db->query('SAVEPOINT sp_1');
+ try {
+ $db->create('audit', [...]);
+ } catch (\Throwable $e) {
+ $db->query('ROLLBACK TO sp_1');
+ throw $e;
+ }
+ $db->query('RELEASE SAVEPOINT sp_1');
+});
+```
+
+## Caveats
+
+- **DDL inside a transaction** (e.g. `CREATE TABLE`) causes MySQL to implicit-commit. The helper guards `rollBack()` with `inTransaction()`, but you should still avoid mixing DDL into a retried transaction.
+- **The closure receives this Database**, not a fresh sibling. Builder state from before the transaction is preserved; CRUD calls inside the closure reset state as usual.
+- **No automatic savepoints.** If you need finer-grained partial rollback, use raw `SAVEPOINT` SQL as shown above.
+
+Continue with [Raw queries](06-raw-queries.md).
diff --git a/docs/06-raw-queries.md b/docs/06-raw-queries.md
new file mode 100644
index 0000000..77aa62d
--- /dev/null
+++ b/docs/06-raw-queries.md
@@ -0,0 +1,107 @@
+# Raw queries
+
+When the query builder doesn't cover your case (CTEs, vendor-specific syntax, EXPLAIN, recursive queries, …) drop down to raw SQL. There are two entry points:
+
+- **`Database::query()`** — execute a complete prepared statement.
+- **`DB::raw()` / `$db->raw()`** — wrap a SQL fragment for use inside the builder.
+
+## Executing raw statements
+
+`query()` returns the same `DataMapperInterface` you'd get from `read()`:
+
+```php
+$result = $db->query(
+ 'SELECT id, title FROM posts WHERE user_id = :id ORDER BY created_at DESC LIMIT 10',
+ [':id' => 5]
+);
+
+foreach ($result->asAssoc()->rows() as $row) {
+ echo $row['title'] . PHP_EOL;
+}
+```
+
+Both `:name`-style and `?`-style placeholders work — the leading `:` on parameter keys is optional.
+
+```php
+$db->query('SELECT * FROM posts WHERE id = :id', ['id' => 7]);
+// Both ":id" and "id" keys map to the :id placeholder.
+```
+
+For statements that don't return rows (`CREATE TABLE`, `INSERT`, …) the call still returns a DataMapper; ignore it or use `numRows()`:
+
+```php
+$inserted = $db->query(
+ 'INSERT INTO audit (event, payload) VALUES (:event, :payload)',
+ [':event' => 'login', ':payload' => '{}']
+)->numRows();
+```
+
+## Inlining SQL inside the builder
+
+`DB::raw()` wraps a string in a `RawQuery` object — the builder treats these as opaque fragments and does not escape or parameterize them:
+
+```php
+DB::select(DB::raw("CONCAT(name, ' ', surname) AS fullname"))
+ ->from('users')
+ ->where(DB::raw('status = 1 OR status = 0'))
+ ->limit(5)
+ ->read();
+```
+
+`RawQuery` is also useful inside `set()`:
+
+```php
+DB::update('counters', [
+ 'value' => DB::raw('value + 1'),
+ 'updated_at' => DB::raw('NOW()'),
+], ['id' => 1]);
+```
+
+Generated SQL: `UPDATE counters SET value = value + 1, updated_at = NOW() WHERE id = :id`
+
+## Mixing raw + bound
+
+Bind explicit parameters by hand on raw fragments:
+
+```php
+$db->query(
+ 'WITH recent AS (
+ SELECT id, score FROM posts WHERE created_at > :since
+ )
+ SELECT id FROM recent WHERE score > :min',
+ [
+ ':since' => '2025-01-01',
+ ':min' => 50,
+ ]
+);
+```
+
+## ⚠️ Safety
+
+The fundamental rule:
+
+> **Never embed unsanitized user input into a `RawQuery` or `query()` SQL string.**
+
+`RawQuery` is *unescaped by definition* — its whole purpose is to bypass quoting. For any value derived from user input, parameterize it:
+
+```php
+// ❌ NEVER
+$db->where(DB::raw("name = '$userInput'"));
+
+// ✅ ALWAYS
+$db->where('name', '=', $userInput);
+```
+
+For identifier-style values (column names, table names) that *must* be dynamic, validate against an allow-list before composing the SQL:
+
+```php
+$allowed = ['id', 'created_at', 'score'];
+if (!in_array($sort, $allowed, true)) {
+ throw new \InvalidArgumentException();
+}
+$db->orderBy($sort, 'DESC');
+```
+
+The builder will safely quote `$sort` as a column identifier, but it cannot tell whether `"id; DROP TABLE users;"` was supposed to be a column.
+
+Continue with [Multiple connections](07-multiple-connections.md).
diff --git a/docs/07-multiple-connections.md b/docs/07-multiple-connections.md
new file mode 100644
index 0000000..505189c
--- /dev/null
+++ b/docs/07-multiple-connections.md
@@ -0,0 +1,88 @@
+# Multiple connections
+
+The static facade is intentionally a *single* connection — most applications only need one. When you need more (replicas, sharded data, a reporting warehouse, a tenant database, …) there are two patterns.
+
+## Pattern 1 — facade + secondary instances
+
+Keep the facade for your primary database; build secondary Databases with `DB::connect()` or `new Database(...)`. Neither touches the facade slot.
+
+```php
+use InitORM\Database\Database;
+use InitORM\Database\Facade\DB;
+
+// Primary, used everywhere via DB::…
+DB::createImmutable([
+ 'dsn' => 'mysql:host=primary.internal;dbname=app;charset=utf8mb4',
+ 'username' => 'app',
+ 'password' => '…',
+]);
+
+// Secondary read-replica, passed around by reference
+$replica = DB::connect([
+ 'dsn' => 'mysql:host=replica.internal;dbname=app;charset=utf8mb4',
+ 'username' => 'app_ro',
+ 'password' => '…',
+]);
+
+$users = $replica->read('users')->asAssoc()->rows();
+```
+
+`DB::connect()` is a thin alias of `new Database(...)` — use whichever reads better in your codebase.
+
+## Pattern 2 — pure instance API, no facade
+
+Skip the facade entirely and pass `DatabaseInterface` around via dependency injection. This is the recommended setup for libraries, microservices, and applications with DI containers.
+
+```php
+use InitORM\Database\Database;
+use InitORM\Database\Interfaces\DatabaseInterface;
+
+final class UserRepository
+{
+ public function __construct(private DatabaseInterface $db) {}
+
+ public function findActive(): array
+ {
+ return $this->db->read('users', ['*'], ['active' => 1])->asAssoc()->rows();
+ }
+}
+
+$primary = new Database([...]);
+$repo = new UserRepository($primary);
+```
+
+## Sharing a connection between sibling Databases
+
+Sometimes you want two Databases that share a live connection (so they're in the same transaction context) but carry independent builder state. Use `withFreshBuilder()`:
+
+```php
+$base = new Database([...]);
+$sibling = $base->withFreshBuilder();
+
+self::assertSame($base->getConnection(), $sibling->getConnection());
+
+$base->select('id')->where('active', '=', 1); // base has builder state
+$sibling->read('users'); // sibling is clean: SELECT * FROM users
+```
+
+Useful when composing a sub-query against the same live transaction without polluting the parent builder.
+
+## Swapping the facade target
+
+`createImmutable()` is single-shot — calling it twice throws to prevent silent overrides. When you genuinely need to swap (typically: between tests), use `replaceImmutable()`:
+
+```php
+// In a test setUp:
+DB::replaceImmutable(SqliteHelper::makeConnection());
+
+// To clear the facade entirely (e.g. in tearDown):
+DB::replaceImmutable(null);
+```
+
+`replaceImmutable()` accepts a credentials array, a `ConnectionInterface`, a `DatabaseInterface`, or `null`.
+
+## Pooling
+
+Neither this package nor `initorm/dbal` pool connections — PDO is created on demand and held for the lifetime of the Connection object. If you need connection pooling, run it in a layer above (e.g. PHP-FPM keeps PDO connections alive within a request; for persistent across-requests pooling, set `PDO::ATTR_PERSISTENT => true` in `options`).
+
+Continue with [Logger and debug](08-logger-and-debug.md).
diff --git a/docs/08-logger-and-debug.md b/docs/08-logger-and-debug.md
new file mode 100644
index 0000000..c6b0382
--- /dev/null
+++ b/docs/08-logger-and-debug.md
@@ -0,0 +1,102 @@
+# Logger and debug
+
+The `log` credential lets you route per-query failure messages to disk, a callable, or any object with a `critical(string)` method — including PSR-3 loggers. `debug` controls whether bound parameters are included in those messages.
+
+## File sink
+
+The simplest form: pass a file path. The Logger writes via `file_put_contents()` with append mode.
+
+```php
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'log' => __DIR__ . '/var/log/db.log',
+]);
+```
+
+The path may contain `{year}`, `{month}`, `{day}` placeholders — they're replaced at write time so daily-rotated logs work out of the box:
+
+```php
+'log' => __DIR__ . '/var/log/db-{year}-{month}-{day}.log',
+// → var/log/db-2025-11-08.log
+```
+
+## Callable sink
+
+Any callable that accepts a single string works:
+
+```php
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'log' => function (string $message): void {
+ error_log($message);
+ },
+]);
+```
+
+`[$object, 'method']` arrays count as callables too:
+
+```php
+$monolog = new \Monolog\Logger('db');
+$monolog->pushHandler(new \Monolog\Handler\StreamHandler('php://stderr'));
+
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'log' => [$monolog, 'critical'],
+]);
+```
+
+## Object sink (PSR-3 friendly)
+
+Any object that exposes a `critical(string $message)` method is accepted. This is the contract `\Psr\Log\LoggerInterface` already satisfies, so you can pass a PSR-3 logger directly:
+
+```php
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'log' => $psr3Logger, // any PSR-3 LoggerInterface
+]);
+```
+
+If your logger uses a different method name, wrap it in a closure:
+
+```php
+'log' => fn(string $msg) => $myLogger->error($msg),
+```
+
+## Debug mode
+
+When `debug: true`, query failure messages are augmented with the bound parameters (JSON-encoded). The default failure message looks like this:
+
+```
+SQLSTATE[42S02]: Base table or view not found: ... no such table: users
+SQL : SELECT * FROM users WHERE id = :id
+```
+
+With `debug: true`:
+
+```
+SQLSTATE[42S02]: Base table or view not found: ... no such table: users
+SQL : SELECT * FROM users WHERE id = :id
+PARAMS : {":id":5}
+```
+
+> Enable `debug` in development only. Parameter dumps will include user-supplied values, possibly including credentials and PII. Treat them as sensitive.
+
+## Combining log + debug
+
+The two settings compose:
+
+```php
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'log' => $psr3Logger,
+ 'debug' => true,
+]);
+```
+
+…which gives you full failure messages (with params) routed through your PSR-3 pipeline.
+
+## What gets logged
+
+Only **query failures** flow into the sink. Successful queries do not produce log entries — that's what the [Query log profiler](09-query-log-profiler.md) is for. The two systems are independent.
+
+Continue with [Query log profiler](09-query-log-profiler.md).
diff --git a/docs/09-query-log-profiler.md b/docs/09-query-log-profiler.md
new file mode 100644
index 0000000..2fb5d51
--- /dev/null
+++ b/docs/09-query-log-profiler.md
@@ -0,0 +1,123 @@
+# Query log profiler
+
+The query log is a per-Connection in-memory buffer of every statement executed while logging is enabled. Use it to inspect what SQL your app actually issues, measure query timings, and catch N+1 problems.
+
+## Enabling
+
+```php
+$db->enableQueryLog();
+
+$db->read('users', ['id', 'name'], ['active' => 1]);
+$db->read('posts', ['id', 'title'], ['user_id' => 5]);
+
+print_r($db->getQueryLogs());
+```
+
+Output:
+
+```
+Array
+(
+ [0] => Array
+ (
+ [query] => SELECT id, name FROM users WHERE active = :active
+ [args] => Array ( [:active] => 1 )
+ [timer] => 0.000642
+ )
+
+ [1] => Array
+ (
+ [query] => SELECT id, title FROM posts WHERE user_id = :user_id
+ [args] => Array ( [:user_id] => 5 )
+ [timer] => 0.000478
+ )
+)
+```
+
+Each entry contains:
+
+| Key | Type | Meaning |
+|---------|-----------------------------------|----------------------------------------------------|
+| `query` | `string` | The SQL string handed to PDO. |
+| `args` | `array\|null` | The bound parameter map (may be `null` for raw). |
+| `timer` | `float` | Wall-clock seconds, `microtime(true)` difference. |
+
+## Disabling
+
+```php
+$db->enableQueryLog();
+$db->read('users'); // recorded
+
+$db->disableQueryLog();
+$db->read('users'); // NOT recorded
+
+print_r($db->getQueryLogs()); // only the first read appears
+```
+
+Disabling preserves existing entries — it only stops new ones from being appended. To clear the buffer, fetch it once and discard; there is no `clearQueryLogs()` on the Database surface (use `$db->getConnection()->setQueryLogs(false)->setQueryLogs(true)` to reset the underlying QueryLogger).
+
+## Bootstrap-time enabling
+
+Set `queryLogs: true` in the credentials array to have the log running before any code touches the Database:
+
+```php
+DB::createImmutable([
+ 'dsn' => '…',
+ 'queryLogs' => true,
+]);
+```
+
+## Use cases
+
+### Finding N+1 problems
+
+```php
+$db->enableQueryLog();
+$users = $db->read('users')->asAssoc()->rows();
+
+foreach ($users as $user) {
+ $posts = $db->read('posts', ['*'], ['user_id' => $user['id']])->asAssoc()->rows();
+ // …
+}
+
+$logs = $db->getQueryLogs();
+echo count($logs); // 1 + number of users → that's an N+1
+```
+
+### Slow-query budget
+
+```php
+$slow = array_filter($db->getQueryLogs(), fn($entry) => $entry['timer'] > 0.05);
+foreach ($slow as $entry) {
+ error_log(sprintf('SLOW: %.3fs %s', $entry['timer'], $entry['query']));
+}
+```
+
+### Dev dashboard
+
+```php
+register_shutdown_function(function () use ($db) {
+ if (!headers_sent()) {
+ header('X-Query-Count: ' . count($db->getQueryLogs()));
+ }
+});
+```
+
+## Cost
+
+The log lives in process memory and grows unbounded. For long-running CLI workers, drain it periodically:
+
+```php
+while ($job = $queue->pop()) {
+ $job->handle();
+
+ // Drain to keep memory flat
+ if (count($db->getQueryLogs()) > 10_000) {
+ $db->getConnection()->setQueryLogs(false)->setQueryLogs(true);
+ }
+}
+```
+
+In production, leave `queryLogs` off unless you're actively profiling — the timing measurement itself is cheap, but unbounded memory growth is not.
+
+Continue with [Facade vs instance](10-facade-vs-instance.md).
diff --git a/docs/10-facade-vs-instance.md b/docs/10-facade-vs-instance.md
new file mode 100644
index 0000000..552b631
--- /dev/null
+++ b/docs/10-facade-vs-instance.md
@@ -0,0 +1,100 @@
+# Facade vs instance
+
+`initorm/database` ships both a static facade (`InitORM\Database\Facade\DB`) and a pure instance API (`new Database(...)` returning `DatabaseInterface`). Both call into the same underlying machinery — pick based on what fits your application.
+
+## Quick comparison
+
+| Concern | Facade `DB::…` | Instance API |
+|----------------------------------|------------------------------------------|-------------------------------------------|
+| Setup | `DB::createImmutable($cfg)` once | `new Database($cfg)` |
+| Type-hint | n/a (static) | `DatabaseInterface` |
+| Dependency injection | Implicit, global state | Explicit, container-friendly |
+| Multiple connections | Awkward — needs `connect()` for extras | Natural — instantiate as many as needed |
+| Testability | Use `DB::replaceImmutable($mock)` | Inject a mock through the constructor |
+| Best for | Scripts, CLIs, smaller apps | Libraries, services, large apps with DI |
+
+## When the facade is right
+
+- The application has **one logical database** and reads it from many places.
+- You don't have a DI container, or threading a Database around feels like overkill.
+- You want short, readable call sites: `DB::read('users')`.
+
+```php
+use InitORM\Database\Facade\DB;
+
+DB::createImmutable($cfg);
+
+// Anywhere in the codebase:
+$users = DB::read('users')->asAssoc()->rows();
+```
+
+## When the instance API is right
+
+- The application has **more than one database** (replicas, tenant DBs, reporting warehouse, …).
+- You inject dependencies through a container or constructor.
+- You write a library and don't want to force a global on your callers.
+- You want concrete types in IDE-friendly type hints.
+
+```php
+use InitORM\Database\Interfaces\DatabaseInterface;
+
+final class UserRepository
+{
+ public function __construct(private DatabaseInterface $db) {}
+}
+```
+
+## You can use both at once
+
+The facade and the instance API don't conflict. `DB::createImmutable()` registers a single shared instance; everything you build through `new Database(...)` or `DB::connect()` lives outside that slot.
+
+```php
+DB::createImmutable($primaryCfg); // facade points at primary
+$replica = DB::connect($replicaCfg); // a separate, non-facade instance
+
+DB::read('users'); // → primary
+$replica->read('users'); // → replica
+```
+
+## Facade and testability
+
+Two patterns work well in tests:
+
+**1. Inject a real Database into a fresh test-only facade slot.**
+
+```php
+protected function setUp(): void
+{
+ parent::setUp();
+ DB::replaceImmutable(new Database([
+ 'driver' => 'sqlite', 'database' => ':memory:', 'charset' => '',
+ ]));
+ DB::query('CREATE TABLE …');
+}
+
+protected function tearDown(): void
+{
+ DB::replaceImmutable(null); // clear the slot for the next test
+ parent::tearDown();
+}
+```
+
+**2. Skip the facade in test code; instantiate `Database` directly.**
+
+This is exactly what the package's own tests do — see [`tests/AbstractDatabaseTestCase.php`](../tests/AbstractDatabaseTestCase.php).
+
+## Why "immutable"?
+
+`createImmutable()` only succeeds once. The previous behaviour (silent override) made code like this hard to reason about:
+
+```php
+// In bootstrap.php:
+DB::createImmutable($prodCfg);
+
+// In some forgotten test fixture:
+DB::createImmutable($testCfg); // ← silently swapped prod for test
+```
+
+Now the second call throws. If you truly need to swap, use `replaceImmutable()` — the explicit name is the whole point.
+
+Continue with [Architecture](11-architecture.md).
diff --git a/docs/11-architecture.md b/docs/11-architecture.md
new file mode 100644
index 0000000..071c702
--- /dev/null
+++ b/docs/11-architecture.md
@@ -0,0 +1,103 @@
+# Architecture
+
+`initorm/database` is a thin glue layer over two independent packages. Understanding the dependency direction and the `__call` chain makes most "where does this method live?" questions answer themselves.
+
+## Package layout
+
+```
+┌─────────────────────────────────────────────────────────┐
+│ initorm/database ← CRUD + transaction + facade │
+│ ├─ Database (concrete) │
+│ ├─ DatabaseInterface (@mixin QueryBuilderInterface) │
+│ └─ Facade\DB (static) │
+└───────┬──────────────────────────┬──────────────────────┘
+ │ │
+ ▼ ▼
+┌─────────────────────┐ ┌─────────────────────────────────┐
+│ initorm/dbal │ │ initorm/query-builder │
+│ Connection (PDO) │ │ QueryBuilder (SQL assembly) │
+│ DataMapper (rows) │ │ Compilers (string-join) │
+└─────────────────────┘ └─────────────────────────────────┘
+```
+
+- `initorm/dbal` and `initorm/query-builder` are **independent** — they have no knowledge of each other.
+- `initorm/database` is the only place where they're stitched together.
+
+## Inside a Database
+
+```
+┌────────────────────────────────────────────────────────┐
+│ Database │
+│ ├─ ConnectionInterface $connection │ ← from DBAL
+│ ├─ QueryBuilderInterface $builder │ ← from QueryBuilder
+│ ├─ QueryBuilderFactoryInterface $queryBuilderFactory │
+│ └─ ?DataMapperInterface $lastResult │ ← for affectedRows()
+└────────────────────────────────────────────────────────┘
+```
+
+The constructor accepts either a credentials array (which it forwards to `Connection::__construct`) or an already-built `ConnectionInterface`. It picks the QueryBuilder driver from `$connection->getDriver()` so identifier quoting is dialect-correct out of the box.
+
+## The `__call` chain
+
+Method calls walk down through three layers, with each layer re-wrapping the chain so callers always end up holding the outer object:
+
+```
+caller → Database::__call(...) → QueryBuilder::method(...)
+ │
+ ├─ if the builder returned $this → return $database (re-wrap)
+ └─ otherwise → return the value as-is
+```
+
+Concrete example:
+
+```php
+$db->select('id')->where('id', '=', 1)->read('users');
+```
+
+1. `select('id')` doesn't exist on Database → `__call` forwards to `$this->builder->select('id')`. The builder returns itself → re-wrapped to `$db`.
+2. `where('id', '=', 1)` → same path; returns `$db`.
+3. `read('users')` exists on Database directly → compiles SQL, calls `$this->connection->query(...)`, resets builder state, returns the `DataMapperInterface`.
+
+The same pattern repeats one layer deeper inside DBAL: `Connection::__call` forwards unknowns to PDO, and `DataMapper::__call` forwards to `PDOStatement`. The whole stack is fluent end-to-end.
+
+## State management
+
+There are two pieces of state inside the Database:
+
+1. **The builder's structure** — `select`, `from`, `where`, `set`, … buckets. Mutated by builder calls; **reset in a `finally` block after every CRUD execution** so the next call starts clean. This was a bug in earlier versions; see [the v2→v3 upgrade guide](12-upgrade-guide.md).
+2. **The builder's parameter bag** — placeholder → value map. Reset in the same `finally` block.
+
+There is no Database-level mutable state beyond `$lastResult` (used by `affectedRows()`), which makes the class safe to use across nested calls inside the same script.
+
+## What is *not* shared with siblings
+
+`withFreshBuilder()` returns a Database that:
+
+- **Shares** the same `ConnectionInterface` (and therefore the same PDO, same transaction context).
+- **Does not share** builder state — a brand-new QueryBuilder is constructed.
+
+This is the right shape for sub-queries: same live connection, independent SQL assembly.
+
+```php
+$base = new Database([...]);
+$sub = $base->withFreshBuilder();
+
+self::assertSame($base->getConnection(), $sub->getConnection()); // ✅
+self::assertNotSame($base, $sub); // ✅
+```
+
+## What is *not* in this package
+
+A common point of confusion: a lot of the surface you'll use lives in *other* packages.
+
+| You're looking for | Look in |
+|------------------------------------------|--------------------------|
+| `setHost`, `setPassword`, `getCharset` | `initorm/dbal` Connection|
+| `asAssoc`, `row`, `rows`, `numRows` | `initorm/dbal` DataMapper|
+| `select`, `where`, `join`, `groupBy`, … | `initorm/query-builder` |
+| Dialect quoting / `RawQuery` | `initorm/query-builder` |
+| Active-record-style models, hooks | `initorm/orm` (separate) |
+
+`initorm/database` itself is small — under 400 lines of code — because almost everything routes through `__call` to one of the layers below.
+
+Continue with [Upgrade guide v2 → v3](12-upgrade-guide.md).
diff --git a/docs/12-upgrade-guide.md b/docs/12-upgrade-guide.md
new file mode 100644
index 0000000..489b8ff
--- /dev/null
+++ b/docs/12-upgrade-guide.md
@@ -0,0 +1,137 @@
+# Upgrade guide — v2 → v3
+
+v3 fixes a handful of long-standing bugs and tightens the public API. Most applications will upgrade with no code changes; a few patterns need touching.
+
+## TL;DR
+
+```bash
+composer require initorm/database:^3.0
+```
+
+Then review the breaking-changes list below.
+
+## Breaking changes
+
+### 1. PHP version
+
+| v2 | v3 |
+|--------|--------|
+| `>=7.4` (in composer.json — actually broken because the dependencies require 8.0+) | `^8.1` |
+
+v3 honestly requires PHP 8.1 — matching the actual constraints of `initorm/query-builder ^2.0`.
+
+### 2. `DB::createImmutable()` is now actually immutable
+
+A second call throws `DatabaseException`. If you really want to swap, use the new explicit `replaceImmutable()`:
+
+```diff
+- DB::createImmutable($newCfg); // silently overrode previous
++ DB::replaceImmutable($newCfg); // explicit swap
+```
+
+`createImmutable()` is the safe default; `replaceImmutable()` is the escape hatch.
+
+### 3. CRUD return semantics
+
+`create()` / `createBatch()` / `update()` / `updateBatch()` / `delete()` now return `bool true` on successful execution (and throw on failure). They no longer return `numRows() > 0` — which used to be misleading when an UPDATE found rows but didn't change any values.
+
+```diff
+ // Don't do this anymore — it's always true when execution succeeded:
+- if ($db->update('users', $data, ['id' => 1])) {
+- $ok = true;
+- }
+
+ // Do one of these:
++ try {
++ $db->update('users', $data, ['id' => 1]);
++ $changed = $db->affectedRows(); // 0 = matched-no-change OR no match
++ } catch (\InitORM\Database\Exceptions\DatabaseException $e) {
++ // execution failure
++ }
+```
+
+`insertId()` still returns the inserted id (now typed `string|false`).
+
+### 4. `insertId()` return type
+
+| v2 | v3 |
+|--------------------------|-----------------|
+| Untyped, returned PDO's `string\|false` | Typed `string\|false` |
+
+The interface and facade `@method` annotation used to claim `int|string|false`, which didn't match the implementation. Now everything agrees on `string|false` — matching `\PDO::lastInsertId()`.
+
+If you have code like `$id = (int) $db->insertId()`, it still works.
+
+### 5. `transaction()` propagates exceptions
+
+Exceptions thrown inside the closure are no longer swallowed. The original throwable is reachable via `getPrevious()`:
+
+```diff
+- $ok = $db->transaction(function () { ... });
+- if (!$ok) {
+- // ??? — no way to know what failed
+- }
+
++ try {
++ $db->transaction(function () { ... });
++ } catch (\InitORM\Database\Exceptions\DatabaseException $e) {
++ $original = $e->getPrevious();
++ // handle $original
++ }
+```
+
+`attempt: 0` now also throws (`DatabaseInvalidArgumentException`) instead of silently doing nothing.
+
+### 6. Builder state is reset after every CRUD call
+
+In v2, this would silently bleed:
+
+```php
+DB::where('id', '=', 1)->delete('users');
+$rows = DB::read('users')->asAssoc()->rows(); // returned 0 rows in v2 — WHERE leaked
+```
+
+In v3, the structure and parameter bag are wiped in a `finally` block after every CRUD execution, so the second read correctly returns all remaining users.
+
+If you relied on the old leaking behaviour (please don't), explicitly re-chain the clauses.
+
+### 7. `DatabaseInterface` no longer declares `__construct`
+
+Constructor signatures on interfaces are LSP-hostile. The interface now only declares behavioural methods; concrete implementations are free to define their own constructor signatures.
+
+If you implemented `DatabaseInterface` yourself, your constructor no longer needs to match the abstract signature.
+
+### 8. `DB::__call` is removed
+
+`DB` is now a strictly static facade — instantiation throws (the constructor is `private`). If you had `(new DB())->...` anywhere, switch to the static form `DB::...`.
+
+### 9. Renamed: `Database::builder()` → `Database::withFreshBuilder()`
+
+The old name was ambiguous — `builder` could mean "the inner builder" or "a new sibling Database". The new name says what it does. The old `builder()` is kept as a deprecated thin alias for the duration of the v3 line.
+
+## Non-breaking improvements
+
+- **PHPStan level 6** clean.
+- **PSR-12** clean (PHPCS in CI).
+- **49 unit tests / 90% line coverage.**
+- **CI workflows** (`phpunit`, `phpcs`, `phpstan`, `composer-validate`) for PHP 8.1–8.4.
+- **`affectedRows()`** new method — returns the rowCount of the most recent CRUD call.
+- **`__clone()`** properly deep-copies the inner builder.
+- **`QueryBuilderFactoryInterface`** can be injected into the constructor — useful for tests.
+- **All exceptions carry descriptive messages.**
+- **All `@method` annotations on the facade** match the actual QueryBuilder signatures (a handful were stale in v2).
+- **README + `docs/`** rewritten end-to-end.
+
+## Migration cheatsheet
+
+| If you do this in v2… | …do this in v3 |
+|-----------------------------------------------------|------------------------------------------------------|
+| `if ($db->create(...)) { … }` | Same — still works, true == success |
+| `if (!$db->update(...)) { return false; }` | Wrap in `try/catch` and inspect `affectedRows()` |
+| `$db->transaction($fn)` and check the return | `try { $db->transaction($fn) } catch (DatabaseException $e)` |
+| `DB::createImmutable(...)` repeatedly in tests | Call `DB::replaceImmutable(...)` in `setUp()` |
+| `(new DB())->...` | `DB::...` |
+| `$db->builder()` | `$db->withFreshBuilder()` (old name still works) |
+| Implement `DatabaseInterface` with own constructor | Just remove `__construct` from interface usage |
+
+Questions, surprises, or migration friction? Open an issue at .
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..e23879a
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,23 @@
+# InitORM Database — documentation
+
+Code-first guides for the `initorm/database` package. Each page is self-contained and includes runnable examples; tests in [`/tests`](../tests) double as living documentation.
+
+| # | Page | Covers |
+|---|---|---|
+| 01 | [Getting started](01-getting-started.md) | install, first connection, first query |
+| 02 | [Configuration](02-configuration.md) | credentials, DSN, charset/collation, PDO options |
+| 03 | [CRUD operations](03-crud.md) | create / createBatch / read / update / updateBatch / delete + affectedRows |
+| 04 | [Query builder surface](04-query-builder.md) | select, where, joins, group/order/limit, sub-queries |
+| 05 | [Transactions](05-transactions.md) | commit, rollback, retries, testMode, exception handling |
+| 06 | [Raw queries](06-raw-queries.md) | `query()`, `DB::raw()`, placeholder safety |
+| 07 | [Multiple connections](07-multiple-connections.md) | `connect()` vs `createImmutable()`, sibling builders |
+| 08 | [Logger and debug](08-logger-and-debug.md) | file / callable / object sinks, debug-mode payloads |
+| 09 | [Query log profiler](09-query-log-profiler.md) | enable/disable, log shape, perf analysis |
+| 10 | [Facade vs instance](10-facade-vs-instance.md) | when to use `DB::…`, when to inject `DatabaseInterface` |
+| 11 | [Architecture](11-architecture.md) | the `__call` chain, dependency map, per-layer responsibilities |
+| 12 | [Upgrade guide v2 → v3](12-upgrade-guide.md) | breaking changes and the migration script |
+
+## Related package docs
+
+- [`initorm/dbal`](https://github.com/InitORM/DBAL/tree/master/docs) — connection lifecycle, DataMapper API.
+- [`initorm/query-builder`](https://github.com/InitORM/QueryBuilder) — the full SQL builder surface this package mixes in.
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..3681fe9
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,35 @@
+
+
+ PSR-12 with a 120-char soft cap on src/, relaxed for tests where expected SQL strings legitimately run long.
+
+ src
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tests/*
+ src/Facade/DB.php
+
+
+
+
+ tests/*
+
+
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..7218d83
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,5 @@
+parameters:
+ level: 6
+ paths:
+ - src
+ treatPhpDocTypesAsCertain: false
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..1d03d61
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,32 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+ src/Interfaces
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Database.php b/src/Database.php
index a2fb140..d915530 100644
--- a/src/Database.php
+++ b/src/Database.php
@@ -1,293 +1,397 @@
-
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\Database;
-
-use PDO;
-use Closure;
-use Throwable;
-use InitORM\DBAL\Connection\Connection;
-use InitORM\DBAL\Connection\Interfaces\ConnectionInterface;
-use InitORM\Database\Interfaces\DatabaseInterface;
-use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface;
-use \InitORM\Database\Exceptions\{DatabaseException, DatabaseInvalidArgumentException};
-use \InitORM\QueryBuilder\{QueryBuilderFactory, QueryBuilderFactoryInterface, QueryBuilderInterface};
-
-class Database implements DatabaseInterface
-{
-
- private ConnectionInterface $connection;
-
- private QueryBuilderInterface $builder;
-
- private QueryBuilderFactoryInterface $queryBuilderFactory;
-
- /**
- * @inheritDoc
- */
- public function __construct($connection)
- {
- if ($connection instanceof ConnectionInterface) {
- $this->connection = $connection;
- } else if (is_array($connection)) {
- $this->connection = new Connection($connection);
- } else {
- throw new DatabaseInvalidArgumentException();
- }
-
- $this->queryBuilderFactory = new QueryBuilderFactory();
-
- $this->builder = $this->queryBuilderFactory->createQueryBuilder($this->connection->getDriver());
- }
-
- /**
- * @param $name
- * @param $arguments
- * @return mixed
- * @throws DatabaseException
- */
- public function __call($name, $arguments)
- {
- if (method_exists($this->builder, $name)) {
- $res = $this->builder->{$name}(...$arguments);
-
- return ($res instanceof QueryBuilderInterface) ? $this : $res;
- }
-
- throw new DatabaseException();
- }
-
- /**
- * @inheritDoc
- */
- public function getConnection(): ConnectionInterface
- {
- return $this->connection;
- }
-
- /**
- * @inheritDoc
- */
- public function getPDO(): PDO
- {
- return $this->getConnection()->getPDO();
- }
-
- /**
- * @inheritDoc
- */
- public function query(string $sqlQuery, ?array $parameters = null, ?array $options = null): DataMapperInterface
- {
- return $this->getConnection()->query($sqlQuery, $parameters, $options);
- }
-
- public function builder(): DatabaseInterface
- {
- $db = clone $this;
- $db->builder = $this->queryBuilderFactory->createQueryBuilder($this->connection->getDriver());
-
- return $db;
- }
-
- /**
- * @inheritDoc
- */
- public function create(?string $table = null, ?array $set = null): bool
- {
- !empty($table) && $this->builder->from($table);
- !empty($set) && $this->builder->set($set);
-
- $res = $this->query($this->builder->generateInsertQuery(), $this->builder->getParameter()->all());
-
- $this->builder->getParameter()->reset();
-
- return $res->numRows() > 0;
- }
-
- /**
- * @inheritDoc
- */
- public function createBatch(?string $table = null, ?array $set = null): bool
- {
- !empty($table) && $this->builder->from($table);
- if (!empty($set)) {
- foreach ($set as $row) {
- $this->builder->set($row);
- }
- }
-
- $res = $this->query($this->builder->generateBatchInsertQuery(), $this->builder->getParameter()->all());
-
- $this->builder->getParameter()->reset();
-
- return $res->numRows() > 0;
- }
-
- /**
- * @inheritDoc
- */
- public function read(?string $table = null, ?array $selectors = null, ?array $conditions = null): DataMapperInterface
- {
- !empty($table) && $this->builder->from($table);
-
- $arguments = $this->builder->getParameter()->all();
- $this->builder->getParameter()->reset();
-
- return $this->query($this->builder->generateSelectQuery($selectors ?? [], $conditions ?? []), $arguments);
- }
-
- /**
- * @inheritDoc
- */
- public function update(?string $table = null, ?array $set = null, ?array $conditions = null): bool
- {
- !empty($table) && $this->builder->from($table);
- !empty($set) && $this->builder->set($set);
-
- if (!empty($conditions)) {
- foreach ($conditions as $column => $value) {
- if (is_string($column)) {
- $this->builder->where($column, $value);
- } else {
- $this->builder->where($value);
- }
- }
- }
-
- $res = $this->query($this->builder->generateUpdateQuery(), $this->builder->getParameter()->all());
-
- $this->builder->getParameter()->reset();
-
- return $res->numRows() > 0;
- }
-
- /**
- * @inheritDoc
- */
- public function updateBatch(string $referenceColumn, ?string $table = null, ?array $set = null, ?array $conditions = null): bool
- {
- !empty($table) && $this->builder->from($table);
- if (!empty($set)) {
- foreach ($set as $row) {
- $this->builder->set($row);
- }
- }
- if (!empty($conditions)) {
- foreach ($conditions as $column => $value) {
- if (is_string($column)) {
- $this->builder->where($column, $value);
- } else {
- $this->builder->where($value);
- }
- }
- }
-
- $res = $this->query($this->builder->generateUpdateBatchQuery($referenceColumn), $this->builder->getParameter()->all());
-
- $this->builder->getParameter()->reset();
-
- return $res->numRows() > 0;
- }
-
- /**
- * @inheritDoc
- */
- public function delete(?string $table, ?array $conditions = null): bool
- {
- !empty($table) && $this->builder->from($table);
-
- if (!empty($conditions)) {
- foreach ($conditions as $column => $value) {
- if (is_string($column)) {
- $this->builder->where($column, $value);
- } else {
- $this->builder->where($value);
- }
- }
- }
-
- $res = $this->query($this->builder->generateDeleteQuery(), $this->builder->getParameter()->all());
-
- $this->builder->getParameter()->reset();
-
- return $res->numRows() > 0;
- }
-
- /**
- * @inheritDoc
- */
- public function transaction(Closure $closure, int $attempt = 1, bool $testMode = false): bool
- {
- if ($attempt < 1) {
- throw new DatabaseInvalidArgumentException("The number of transaction attempts cannot be less than 1.");
- }
- if ($this->getConnection()->getPDO()->inTransaction()) {
- throw new DatabaseException("Without ending one transaction, another cannot be started.");
- }
- $res = false;
- for ($i = 0; $i < $attempt; ++$i) {
- try {
- $this->getConnection()->getPDO()->beginTransaction();
- call_user_func_array($closure, [$this]);
- $res = $testMode
- ? $this->getConnection()->getPDO()->rollBack()
- : $this->getConnection()->getPDO()->commit();
- if ($res) {
- break;
- }
- } catch (Throwable $e) {
- $res = $this->getConnection()->getPDO()->rollBack();
- continue;
- }
- }
- return $res;
- }
-
- /**
- * @inheritDoc
- */
- public function insertId()
- {
- return $this->getPDO()->lastInsertId();
- }
-
- /**
- * @inheritDoc
- */
- public function enableQueryLog(): self
- {
- $this->getConnection()->setQueryLogs(true);
-
- return $this;
- }
-
- /**
- * @inheritDoc
- */
- public function disableQueryLog(): self
- {
- $this->getConnection()->setQueryLogs(false);
-
- return $this;
- }
-
- /**
- * @inheritDoc
- */
- public function getQueryLogs(): array
- {
- return $this->getConnection()->getQueryLogs();
- }
-
-}
+|ConnectionInterface $connection Either an
+ * already-built connection or a credentials array suitable for
+ * {@see Connection::__construct()}. PHP's native TypeError fires if
+ * a value of any other type is passed in.
+ * @param QueryBuilderFactoryInterface|null $queryBuilderFactory Injection
+ * hook for tests / DI containers. Defaults to {@see QueryBuilderFactory}.
+ */
+ public function __construct(
+ array|ConnectionInterface $connection,
+ ?QueryBuilderFactoryInterface $queryBuilderFactory = null
+ ) {
+ if ($connection instanceof ConnectionInterface) {
+ $this->connection = $connection;
+ } else {
+ $this->connection = new Connection($connection);
+ }
+
+ $this->queryBuilderFactory = $queryBuilderFactory ?? new QueryBuilderFactory();
+ $this->builder = $this->queryBuilderFactory->createQueryBuilder($this->connection->getDriver());
+ }
+
+ /**
+ * Deep-clone the inner builder so spinning off a Database via {@see self::builder()}
+ * leaves the original builder state untouched.
+ */
+ public function __clone(): void
+ {
+ $this->builder = clone $this->builder;
+ }
+
+ /**
+ * Forward unknown calls to the inner query builder. When the builder
+ * returns itself (chainable methods), the call is re-wrapped to return
+ * this Database so fluent chains continue across the wrapper boundary.
+ *
+ * @param array $arguments
+ *
+ * @throws DatabaseException When the method does not exist on the builder.
+ */
+ public function __call(string $name, array $arguments): mixed
+ {
+ if (!method_exists($this->builder, $name)) {
+ throw new DatabaseException(sprintf(
+ 'Method "%s::%s" does not exist.',
+ QueryBuilderInterface::class,
+ $name
+ ));
+ }
+
+ $result = $this->builder->{$name}(...$arguments);
+
+ return $result instanceof QueryBuilderInterface ? $this : $result;
+ }
+
+ public function getConnection(): ConnectionInterface
+ {
+ return $this->connection;
+ }
+
+ public function getPDO(): PDO
+ {
+ return $this->connection->getPDO();
+ }
+
+ public function query(string $sqlQuery, ?array $parameters = null, ?array $options = null): DataMapperInterface
+ {
+ return $this->connection->query($sqlQuery, $parameters, $options);
+ }
+
+ /**
+ * Spawn a sibling Database that shares this connection but carries a
+ * fresh builder — useful for composing independent queries off the same
+ * live connection (e.g. a sub-query under a parent query).
+ */
+ public function withFreshBuilder(): DatabaseInterface
+ {
+ $clone = clone $this;
+ $clone->builder = $this->queryBuilderFactory->createQueryBuilder($this->connection->getDriver());
+
+ return $clone;
+ }
+
+ /**
+ * @deprecated since 3.0 — use {@see self::withFreshBuilder()} instead. Kept
+ * as a thin alias for the duration of the v3 line.
+ */
+ public function builder(): DatabaseInterface
+ {
+ return $this->withFreshBuilder();
+ }
+
+ public function create(?string $table = null, ?array $set = null): bool
+ {
+ if (!empty($table)) {
+ $this->builder->from($table);
+ }
+ if (!empty($set)) {
+ $this->builder->set($set);
+ }
+
+ $this->executeBuilderQuery($this->builder->generateInsertQuery());
+
+ return true;
+ }
+
+ public function createBatch(?string $table = null, ?array $set = null): bool
+ {
+ if (!empty($table)) {
+ $this->builder->from($table);
+ }
+ if (!empty($set)) {
+ foreach ($set as $row) {
+ $this->builder->set($row);
+ }
+ }
+
+ $this->executeBuilderQuery($this->builder->generateBatchInsertQuery());
+
+ return true;
+ }
+
+ public function read(
+ ?string $table = null,
+ ?array $selectors = null,
+ ?array $conditions = null
+ ): DataMapperInterface {
+ if (!empty($table)) {
+ $this->builder->from($table);
+ }
+
+ // generateSelectQuery($selectors, $conditions) registers parameters
+ // via where(); the snapshot MUST be taken AFTER compile, not before.
+ $sql = $this->builder->generateSelectQuery($selectors ?? [], $conditions ?? []);
+ $parameters = $this->builder->getParameter()->all();
+
+ try {
+ $this->lastResult = $this->connection->query($sql, $parameters);
+ } finally {
+ $this->builder->resetStructure();
+ $this->builder->getParameter()->reset();
+ }
+
+ return $this->lastResult;
+ }
+
+ public function update(?string $table = null, ?array $set = null, ?array $conditions = null): bool
+ {
+ if (!empty($table)) {
+ $this->builder->from($table);
+ }
+ if (!empty($set)) {
+ $this->builder->set($set);
+ }
+
+ $this->applyConditions($conditions);
+
+ $this->executeBuilderQuery($this->builder->generateUpdateQuery());
+
+ return true;
+ }
+
+ public function updateBatch(
+ string $referenceColumn,
+ ?string $table = null,
+ ?array $set = null,
+ ?array $conditions = null
+ ): bool {
+ if (!empty($table)) {
+ $this->builder->from($table);
+ }
+ if (!empty($set)) {
+ foreach ($set as $row) {
+ $this->builder->set($row);
+ }
+ }
+
+ $this->applyConditions($conditions);
+
+ $this->executeBuilderQuery($this->builder->generateUpdateBatchQuery($referenceColumn));
+
+ return true;
+ }
+
+ public function delete(?string $table = null, ?array $conditions = null): bool
+ {
+ if (!empty($table)) {
+ $this->builder->from($table);
+ }
+
+ $this->applyConditions($conditions);
+
+ $this->executeBuilderQuery($this->builder->generateDeleteQuery());
+
+ return true;
+ }
+
+ /**
+ * @throws DatabaseException When a transaction is already open, or when all
+ * retry attempts fail. The original {@see Throwable} is preserved
+ * as the previous-exception chain.
+ */
+ public function transaction(Closure $closure, int $attempt = 1, bool $testMode = false): bool
+ {
+ if ($attempt < 1) {
+ throw new DatabaseInvalidArgumentException('The number of transaction attempts cannot be less than 1.');
+ }
+
+ $pdo = $this->connection->getPDO();
+ if ($pdo->inTransaction()) {
+ throw new DatabaseException('Cannot start a transaction while another is already in progress.');
+ }
+
+ $lastError = null;
+ for ($i = 0; $i < $attempt; ++$i) {
+ try {
+ $pdo->beginTransaction();
+ $closure($this);
+
+ $committed = $testMode ? $pdo->rollBack() : $pdo->commit();
+
+ if ($committed) {
+ return true;
+ }
+ } catch (Throwable $e) {
+ $lastError = $e;
+ // beginTransaction()/commit() can throw mid-flight; we may or
+ // may not still be in a transaction at this point. Guard the
+ // rollback to avoid PDO's "no active transaction" error.
+ // @phpstan-ignore-next-line booleanNot.exprNotBoolean
+ if ($pdo->inTransaction() === true) {
+ try {
+ $pdo->rollBack();
+ } catch (Throwable $rollbackError) {
+ // The original error is what users care about; surface
+ // a rollback failure only as part of the message.
+ $lastError = new DatabaseException(
+ sprintf(
+ 'Rollback failed after a failed transaction attempt: %s (original: %s)',
+ $rollbackError->getMessage(),
+ $e->getMessage()
+ ),
+ (int) $e->getCode(),
+ $e
+ );
+ }
+ }
+ }
+ }
+
+ throw new DatabaseException(
+ sprintf('Transaction failed after %d attempt(s): %s', $attempt, $lastError?->getMessage() ?? 'unknown'),
+ $lastError !== null ? (int) $lastError->getCode() : 0,
+ $lastError
+ );
+ }
+
+ /**
+ * Forward to {@see \PDO::lastInsertId()}.
+ *
+ * @return string|false False when the driver does not provide one (or no
+ * row has been inserted on this connection yet).
+ */
+ public function insertId(): string|false
+ {
+ return $this->connection->getPDO()->lastInsertId();
+ }
+
+ /**
+ * Number of rows affected by the most recent CRUD call on this Database
+ * instance (create / createBatch / update / updateBatch / delete / read).
+ * Returns 0 when no CRUD call has executed yet.
+ *
+ * Driver caveat: {@see \PDOStatement::rowCount()} is unreliable for SELECT
+ * on drivers that don't buffer results (e.g. unbuffered MySQL, SQLite for
+ * SELECT). For INSERT/UPDATE/DELETE on common drivers it returns the
+ * affected-row count, which is the intended use here.
+ */
+ public function affectedRows(): int
+ {
+ return $this->lastResult?->numRows() ?? 0;
+ }
+
+ public function enableQueryLog(): static
+ {
+ $this->connection->setQueryLogs(true);
+
+ return $this;
+ }
+
+ public function disableQueryLog(): static
+ {
+ $this->connection->setQueryLogs(false);
+
+ return $this;
+ }
+
+ public function getQueryLogs(): array
+ {
+ return $this->connection->getQueryLogs();
+ }
+
+ /**
+ * Compile-and-execute a SQL string produced by the builder, then reset the
+ * builder's parameter bag so a follow-up CRUD call starts clean.
+ */
+ private function executeBuilderQuery(string $sql): DataMapperInterface
+ {
+ $parameters = $this->builder->getParameter()->all();
+
+ try {
+ $this->lastResult = $this->connection->query($sql, $parameters);
+ } finally {
+ // Wipe builder state so the next CRUD call starts with a clean
+ // structure (no leftover where/from/select/set buckets).
+ $this->builder->resetStructure();
+ $this->builder->getParameter()->reset();
+ }
+
+ return $this->lastResult;
+ }
+
+ /**
+ * Translate the CRUD-helper $conditions shape into builder where() calls.
+ *
+ * - String keys → where(key, '=', value)
+ * - Integer keys → where(value) (caller-supplied RawQuery or column name)
+ *
+ * @param array|null $conditions
+ */
+ private function applyConditions(?array $conditions): void
+ {
+ if (empty($conditions)) {
+ return;
+ }
+ foreach ($conditions as $column => $value) {
+ if (is_string($column)) {
+ $this->builder->where($column, '=', $value);
+ } else {
+ $this->builder->where($value);
+ }
+ }
+ }
+}
diff --git a/src/Exceptions/DatabaseException.php b/src/Exceptions/DatabaseException.php
index 126122e..2bfa4fa 100644
--- a/src/Exceptions/DatabaseException.php
+++ b/src/Exceptions/DatabaseException.php
@@ -1,21 +1,21 @@
-
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\Database\Exceptions;
-
-use Exception;
-
-class DatabaseException extends Exception
-{
-}
+
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\Database\Exceptions;
-
-use InvalidArgumentException;
-
-class DatabaseInvalidArgumentException extends InvalidArgumentException
-{
-}
+
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\Database\Facade;
-
-use PDO;
-use Closure;
-use InitORM\Database\Database;
-use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface;
-use \InitORM\QueryBuilder\{ParameterInterface, RawQuery};
-use InitORM\Database\Exceptions\DatabaseException;
-use InitORM\Database\Interfaces\DatabaseInterface;
-use InitORM\DBAL\Connection\Interfaces\ConnectionInterface;
-
-/**
- * @mixin DatabaseInterface
- * @method static PDO getPDO()
- * @method static DatabaseInterface enableQueryLog()
- * @method static DatabaseInterface disableQueryLog()
- * @method static array getQueryLogs()
- * @method static ConnectionInterface getConnection()
- * @method static DatabaseInterface builder()
- * @method static DataMapperInterface query(string $sqlQuery, ?array $parameters = null, ?array $options = null)
- * @method static int|string insertId()
- * @method static bool transaction(Closure $closure, int $attempt = 1, bool $testMode = false)
- * @method static bool create(?string $table = null, ?array $set = null)
- * @method static bool createBatch(?string $table = null, ?array $set = null)
- * @method static DataMapperInterface read(?string $table = null, ?array $selectors = null, ?array $conditions = null)
- * @method static bool update(?string $table = null, ?array $set = null, ?array $conditions = null)
- * @method static bool updateBatch(string $referenceColumn, ?string $table = null, ?array $set = null, ?array $conditions = null)
- * @method static bool delete(?string $table, ?array $conditions = null)
- * @method static ParameterInterface getParameter()
- * @method static DatabaseInterface setParameter(string $key, mixed $value)
- * @method static DatabaseInterface setParameters(array $parameters = [])
- * @method static DatabaseInterface select(string|RawQuery|string[]|RawQuery[] ...$columns)
- * @method static DatabaseInterface selectCount(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectCountDistinct(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectMax(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectMin(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectAvg(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectAs(RawQuery|string $column, string $alias)
- * @method static DatabaseInterface selectUpper(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectLower(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectLength(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectMid(RawQuery|string $column, int $offset, int $length, ?string $alias = null)
- * @method static DatabaseInterface selectLeft(RawQuery|string $column, int $length, ?string $alias = null)
- * @method static DatabaseInterface selectRight(RawQuery|string $column, int $length, ?string $alias = null)
- * @method static DatabaseInterface selectDistinct(RawQuery|string $column, ?string $alias = null)
- * @method static DatabaseInterface selectCoalesce(RawQuery|string $column, mixed $default = '0', ?string $alias = null)
- * @method static DatabaseInterface selectSum(string|RawQuery $column, ?string $alias = null)
- * @method static DatabaseInterface selectConcat(array $columns, ?string $alias = null)
- * @method static DatabaseInterface from(RawQuery|string $table, ?string $alias = null)
- * @method static DatabaseInterface addFrom(RawQuery|string $table, ?string $alias = null)
- * @method static DatabaseInterface table(string|RawQuery $table)
- * @method static DatabaseInterface groupBy(string|RawQuery|array ...$columns)
- * @method static DatabaseInterface join(RawQuery|string $table, RawQuery|string|Closure $onStmt = null, string $type = 'INNER')
- * @method static DatabaseInterface selfJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt)
- * @method static DatabaseInterface innerJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt)
- * @method static DatabaseInterface leftJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt)
- * @method static DatabaseInterface rightJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt)
- * @method static DatabaseInterface leftOuterJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt)
- * @method static DatabaseInterface rightOuterJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt)
- * @method static DatabaseInterface naturalJoin(string|RawQuery $table, string|RawQuery|Closure $onStmt)
- * @method static DatabaseInterface orderBy(RawQuery|string $column, string $soft = 'ASC')
- * @method static DatabaseInterface where(RawQuery|string $column, string $operator = '=', mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface having(RawQuery|string $column, string $operator = '=', mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface on(RawQuery|string $column, string $operator = '=', mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface set(RawQuery|array|string $column, mixed $value = null, bool $strict = true)
- * @method static DatabaseInterface addSet(RawQuery|array|string $column, mixed $value = null, bool $strict = true)
- * @method static DatabaseInterface andWhere(string|RawQuery $column, string $operator = '=', mixed $value = null)
- * @method static DatabaseInterface orWhere(string|RawQuery $column, string $operator = '=', mixed $value = null)
- * @method static DatabaseInterface between(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND')
- * @method static DatabaseInterface orBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null)
- * @method static DatabaseInterface andBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null)
- * @method static DatabaseInterface notBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null, string $logical = 'AND')
- * @method static DatabaseInterface orNotBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null)
- * @method static DatabaseInterface andNotBetween(string|RawQuery $column, mixed $firstValue = null, mixed $lastValue = null)
- * @method static DatabaseInterface findInSet(string|RawQuery $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface andFindInSet(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface orFindInSet(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface notFindInSet(string|RawQuery $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface andNotFindInSet(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface orNotFindInSet(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface whereIn(string|RawQuery $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface whereNotIn(string|RawQuery $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface orWhereIn(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface orWhereNotIn(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface andWhereIn(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface andWhereNotIn(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface regexp(string|RawQuery $column, string|RawQuery $value, string $logical = 'AND')
- * @method static DatabaseInterface andRegexp(string|RawQuery $column, string|RawQuery $value)
- * @method static DatabaseInterface orRegexp(string|RawQuery $column, string|RawQuery $value)
- * @method static DatabaseInterface soundex(string|RawQuery $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface andSoundex(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface orSoundex(string|RawQuery $column, mixed $value = null)
- * @method static DatabaseInterface whereIsNull(string|RawQuery $column, string $logical = 'AND')
- * @method static DatabaseInterface orWhereIsNull(string|RawQuery $column)
- * @method static DatabaseInterface andWhereIsNull(string|RawQuery $column)
- * @method static DatabaseInterface whereIsNotNull(string|RawQuery $column, string $logical = 'AND')
- * @method static DatabaseInterface orWhereIsNotNull(string|RawQuery $column)
- * @method static DatabaseInterface andWhereIsNotNull(string|RawQuery $column)
- * @method static DatabaseInterface offset(int $offset = 0)
- * @method static DatabaseInterface limit(int $limit)
- * @method static DatabaseInterface like(string|RawQuery|array $column, mixed $value = null, string $type = 'both', string $logical = 'AND')
- * @method static DatabaseInterface orLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both')
- * @method static DatabaseInterface andLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both')
- * @method static DatabaseInterface notLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both', string $logical = 'AND')
- * @method static DatabaseInterface orNotLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both')
- * @method static DatabaseInterface andNotLike(string|RawQuery|array $column, mixed $value = null, string $type = 'both')
- * @method static DatabaseInterface startLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface orStartLike(string|RawQuery|array $column, mixed $value = null)
- * @method static DatabaseInterface andStartLike(string|RawQuery|array $column, mixed $value = null)
- * @method static DatabaseInterface notStartLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface orStartNotLike(string|RawQuery|array $column, mixed $value = null)
- * @method static DatabaseInterface andStartNotLike(string|RawQuery|array $column, mixed $value = null)
- * @method static DatabaseInterface endLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface orEndLike(string|RawQuery|array $column, mixed $value = null)
- * @method static DatabaseInterface andEndLike(string|RawQuery|array $column, mixed $value = null)
- * @method static DatabaseInterface notEndLike(string|RawQuery|array $column, mixed $value = null, string $logical = 'AND')
- * @method static DatabaseInterface orEndNotLike(string|RawQuery|array $column, mixed $value = null)
- * @method static DatabaseInterface andEndNotLike(string|RawQuery|array $column, mixed $value = null)
- * @method static RawQuery subQuery(Closure $closure, ?string $alias = null, bool $isIntervalQuery = true)
- * @method static DatabaseInterface group(Closure $closure)
- * @method static RawQuery raw(mixed $rawQuery)
- */
-final class DB
-{
-
- private static DatabaseInterface $database;
-
- /**
- * @param $name
- * @param $arguments
- * @return mixed
- * @throws DatabaseException
- */
- public function __call($name, $arguments)
- {
- return self::getDatabase()->{$name}(...$arguments);
- }
-
- /**
- * @param $name
- * @param $arguments
- * @return mixed
- * @throws DatabaseException
- */
- public static function __callStatic($name, $arguments)
- {
- return self::getDatabase()->{$name}(...$arguments);
- }
-
- /**
- * @param array|ConnectionInterface $connection
- * @return void
- */
- public static function createImmutable($connection): void
- {
- self::$database = self::connect($connection);
- }
-
- /**
- * @param array|ConnectionInterface $connection
- * @return DatabaseInterface
- */
- public static function connect($connection): DatabaseInterface
- {
- return new Database($connection);
- }
-
- /**
- * @return DatabaseInterface
- * @throws DatabaseException
- */
- public static function getDatabase(): DatabaseInterface
- {
- if (!isset(self::$database)) {
- throw new DatabaseException('To create an immutable, first use the "createImmutable()" method.');
- }
-
- return self::$database;
- }
-
-}
+|ConnectionInterface $connection
+ *
+ * @throws DatabaseException When an immutable instance is
+ * already set.
+ * @throws DatabaseInvalidArgumentException When $connection is invalid.
+ */
+ public static function createImmutable(array|ConnectionInterface $connection): DatabaseInterface
+ {
+ if (self::$database !== null) {
+ throw new DatabaseException(
+ 'An immutable Database instance has already been set. '
+ . 'Call DB::replaceImmutable() to swap it explicitly.'
+ );
+ }
+
+ self::$database = self::connect($connection);
+
+ return self::$database;
+ }
+
+ /**
+ * Explicitly replace the shared facade target. Use when an application
+ * truly needs to reset the connection (e.g. between test cases).
+ *
+ * Pass null to clear the facade entirely.
+ *
+ * @param array|ConnectionInterface|DatabaseInterface|null $connection
+ */
+ public static function replaceImmutable(
+ array|ConnectionInterface|DatabaseInterface|null $connection
+ ): ?DatabaseInterface {
+ if ($connection === null) {
+ self::$database = null;
+
+ return null;
+ }
+
+ self::$database = $connection instanceof DatabaseInterface
+ ? $connection
+ : self::connect($connection);
+
+ return self::$database;
+ }
+
+ /**
+ * Build a fresh, non-facade Database. The returned instance does not
+ * touch the shared facade slot — useful for working with secondary
+ * connections.
+ *
+ * @param array|ConnectionInterface $connection
+ *
+ * @throws DatabaseInvalidArgumentException When $connection is invalid.
+ */
+ public static function connect(array|ConnectionInterface $connection): DatabaseInterface
+ {
+ return new Database($connection);
+ }
+
+ /**
+ * The shared facade instance.
+ *
+ * @throws DatabaseException When no immutable instance has been set yet.
+ */
+ public static function getDatabase(): DatabaseInterface
+ {
+ if (self::$database === null) {
+ throw new DatabaseException(
+ 'No immutable Database instance is configured. Call DB::createImmutable($connection) first.'
+ );
+ }
+
+ return self::$database;
+ }
+
+ /**
+ * @param array $arguments
+ *
+ * @throws DatabaseException When no immutable instance is set, or when the
+ * method does not exist on the underlying Database / builder.
+ */
+ public static function __callStatic(string $name, array $arguments): mixed
+ {
+ return self::getDatabase()->{$name}(...$arguments);
+ }
+}
diff --git a/src/Interfaces/DatabaseInterface.php b/src/Interfaces/DatabaseInterface.php
index 1a8ac65..3f0c924 100644
--- a/src/Interfaces/DatabaseInterface.php
+++ b/src/Interfaces/DatabaseInterface.php
@@ -1,162 +1,235 @@
-
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\Database\Interfaces;
-
-use PDO;
-use Closure;
-use InitORM\Database\Exceptions\DatabaseException;
-use InitORM\Database\Exceptions\DatabaseInvalidArgumentException;
-use InitORM\DBAL\Connection\Exceptions\ConnectionException;
-use InitORM\DBAL\Connection\Interfaces\ConnectionInterface;
-use InitORM\DBAL\DataMapper\Exceptions\DataMapperException;
-use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface;
-use InitORM\QueryBuilder\Exceptions\QueryBuilderException;
-use InitORM\QueryBuilder\QueryBuilderInterface;
-
-/**
- * @mixin QueryBuilderInterface
- */
-interface DatabaseInterface
-{
-
- /**
- * @param array|ConnectionInterface $connection
- * @throws DatabaseInvalidArgumentException
- */
- public function __construct($connection);
-
- /**
- * @return self
- */
- public function builder(): self;
-
- /**
- * @return ConnectionInterface
- */
- public function getConnection(): ConnectionInterface;
-
- /**
- * @return PDO
- * @throws ConnectionException
- */
- public function getPDO(): PDO;
-
- /**
- * @param string $sqlQuery
- * @param array|null $parameters
- * @param array|null $options
- * @return DataMapperInterface
- * @throws DataMapperException
- * @throws ConnectionException
- */
- public function query(string $sqlQuery, ?array $parameters = null, ?array $options = null): DataMapperInterface;
-
- /**
- * @param string|null $table
- * @param array|null $set
- * @return bool
- * @throws QueryBuilderException
- * @throws DataMapperException
- * @throws ConnectionException
- */
- public function create(?string $table = null, ?array $set = null): bool;
-
- /**
- * @param string|null $table
- * @param array|null $set
- * @return bool
- * @throws QueryBuilderException
- * @throws DataMapperException
- * @throws ConnectionException
- */
- public function createBatch(?string $table = null, ?array $set = null): bool;
-
- /**
- * @param string|null $table
- * @param array|null $selectors
- * @param array|null $conditions
- * @return DataMapperInterface
- * @throws QueryBuilderException
- * @throws DataMapperException
- * @throws ConnectionException
- */
- public function read(?string $table = null, ?array $selectors = null, ?array $conditions = null): DataMapperInterface;
-
- /**
- * @param string|null $table
- * @param array|null $set
- * @param array|null $conditions
- * @return bool
- * @throws QueryBuilderException
- * @throws DataMapperException
- * @throws ConnectionException
- */
- public function update(?string $table = null, ?array $set = null, ?array $conditions = null): bool;
-
- /**
- * @param string $referenceColumn
- * @param string|null $table
- * @param array|null $set
- * @param array|null $conditions
- * @return bool
- * @throws QueryBuilderException
- * @throws DataMapperException
- * @throws ConnectionException
- */
- public function updateBatch(string $referenceColumn, ?string $table = null, ?array $set = null, ?array $conditions = null): bool;
-
- /**
- * @param string|null $table
- * @param array|null $conditions
- * @return bool
- * @throws QueryBuilderException
- * @throws DataMapperException
- * @throws ConnectionException
- */
- public function delete(?string $table, ?array $conditions = null): bool;
-
- /**
- * @param Closure $closure
- * @param int $attempt
- * @param bool $testMode
- * @return bool
- * @throws ConnectionException
- * @throws DatabaseException
- * @throws ConnectionException
- */
- public function transaction(Closure $closure, int $attempt = 1, bool $testMode = false): bool;
-
- /**
- * @return int|string|false
- * @throws ConnectionException
- */
- public function insertId();
-
- /**
- * @return self
- */
- public function enableQueryLog(): self;
-
- /**
- * @return self
- */
- public function disableQueryLog(): self;
-
- /**
- * @return array
- */
- public function getQueryLogs(): array;
-
-}
+getConnection()->getPDO()} — opens the
+ * underlying PDO connection on demand.
+ *
+ * @throws ConnectionException When PDO cannot connect.
+ */
+ public function getPDO(): PDO;
+
+ /**
+ * Prepare and execute a raw SQL statement. The result wraps the underlying
+ * {@see \PDOStatement} in a fluent {@see DataMapperInterface} for
+ * row/object fetching.
+ *
+ * @param array|null $parameters Named parameters; the
+ * leading `:` on each key is optional.
+ * @param array|null $options PDO prepare options.
+ *
+ * @throws SQLExecuteException When the statement fails to prepare/execute.
+ * @throws ConnectionException When the connection cannot be established.
+ */
+ public function query(string $sqlQuery, ?array $parameters = null, ?array $options = null): DataMapperInterface;
+
+ /**
+ * Spawn a sibling Database that shares this connection but carries a
+ * fresh, empty builder. Useful when composing independent queries on the
+ * same live connection without leaking builder state.
+ */
+ public function withFreshBuilder(): self;
+
+ /**
+ * @deprecated since 3.0 — use {@see self::withFreshBuilder()}.
+ */
+ public function builder(): self;
+
+ /**
+ * Compile any pending builder state into an INSERT and execute it.
+ *
+ * @param string|null $table Optional FROM table; when null
+ * the table must already be set on the builder via {@code from()}.
+ * @param array|null $set Optional column → value map for
+ * the row to insert.
+ *
+ * @return bool True on successful execution. (Throws on failure rather
+ * than returning false, so a true return is unambiguous.)
+ *
+ * @throws QueryBuilderException
+ * @throws SQLExecuteException
+ * @throws ConnectionException
+ * @throws DataMapperException
+ */
+ public function create(?string $table = null, ?array $set = null): bool;
+
+ /**
+ * Compile any pending builder state into a multi-row INSERT and execute it.
+ *
+ * @param array>|null $set One row per element.
+ *
+ * @return bool True on successful execution.
+ *
+ * @throws QueryBuilderException
+ * @throws SQLExecuteException
+ * @throws ConnectionException
+ * @throws DataMapperException
+ */
+ public function createBatch(?string $table = null, ?array $set = null): bool;
+
+ /**
+ * Compile any pending builder state into a SELECT and execute it.
+ *
+ * @param array|null $selectors
+ * Optional columns. When null, the previously-configured select list
+ * (or "*") is used.
+ * @param array|null $conditions
+ * Optional WHERE shortcuts: string-keyed entries become
+ * {@code where(key, '=', value)}; integer-keyed entries are passed
+ * as a single argument to {@code where()}.
+ *
+ * @throws QueryBuilderException
+ * @throws SQLExecuteException
+ * @throws ConnectionException
+ * @throws DataMapperException
+ */
+ public function read(
+ ?string $table = null,
+ ?array $selectors = null,
+ ?array $conditions = null
+ ): DataMapperInterface;
+
+ /**
+ * Compile any pending builder state into an UPDATE and execute it.
+ *
+ * @param array|null $set Columns to update.
+ * @param array|null $conditions See {@see self::read()}.
+ *
+ * @return bool True on successful execution.
+ *
+ * @throws QueryBuilderException
+ * @throws SQLExecuteException
+ * @throws ConnectionException
+ * @throws DataMapperException
+ */
+ public function update(?string $table = null, ?array $set = null, ?array $conditions = null): bool;
+
+ /**
+ * Compile any pending builder state into a CASE/WHEN-based batch UPDATE
+ * keyed by {@code $referenceColumn} and execute it.
+ *
+ * @param array>|null $set One row per element.
+ * @param array|null $conditions See {@see self::read()}.
+ *
+ * @return bool True on successful execution.
+ *
+ * @throws QueryBuilderException
+ * @throws SQLExecuteException
+ * @throws ConnectionException
+ * @throws DataMapperException
+ */
+ public function updateBatch(
+ string $referenceColumn,
+ ?string $table = null,
+ ?array $set = null,
+ ?array $conditions = null
+ ): bool;
+
+ /**
+ * Compile any pending builder state into a DELETE and execute it.
+ *
+ * @param array|null $conditions See {@see self::read()}.
+ *
+ * @return bool True on successful execution.
+ *
+ * @throws QueryBuilderException
+ * @throws SQLExecuteException
+ * @throws ConnectionException
+ * @throws DataMapperException
+ */
+ public function delete(?string $table = null, ?array $conditions = null): bool;
+
+ /**
+ * Run {@code $closure} inside a transaction with optional retries.
+ *
+ * The closure receives this Database instance as its only argument and is
+ * expected to throw to abort the transaction. On exception, the current
+ * transaction is rolled back and the next attempt begins. When all attempts
+ * fail, the last error is re-thrown wrapped in a {@see DatabaseException}.
+ *
+ * When {@code $testMode} is true the transaction is rolled back instead of
+ * committed even on success — useful for integration tests that want to
+ * exercise the closure without persisting changes.
+ *
+ * @throws DatabaseInvalidArgumentException When {@code $attempt < 1}.
+ * @throws DatabaseException When a transaction is already
+ * in progress, or when all retries fail.
+ */
+ public function transaction(Closure $closure, int $attempt = 1, bool $testMode = false): bool;
+
+ /**
+ * Forward to {@see \PDO::lastInsertId()}.
+ *
+ * @return string|false False when the driver does not support sequences
+ * or no row has been inserted on this connection yet.
+ *
+ * @throws ConnectionException
+ */
+ public function insertId(): string|false;
+
+ /**
+ * Number of rows affected by the most recent CRUD call on this Database
+ * (create/createBatch/update/updateBatch/delete/read). Returns 0 when no
+ * CRUD call has executed yet.
+ */
+ public function affectedRows(): int;
+
+ /**
+ * Enable the connection-level query log. Each subsequent statement is
+ * recorded with its parameters and execution time, retrievable via
+ * {@see self::getQueryLogs()}.
+ */
+ public function enableQueryLog(): static;
+
+ /**
+ * Disable the query log without clearing previously-collected entries.
+ */
+ public function disableQueryLog(): static;
+
+ /**
+ * @return array|null, timer: float}>
+ */
+ public function getQueryLogs(): array;
+}
diff --git a/tests/AbstractDatabaseTestCase.php b/tests/AbstractDatabaseTestCase.php
new file mode 100644
index 0000000..ac0aa05
--- /dev/null
+++ b/tests/AbstractDatabaseTestCase.php
@@ -0,0 +1,25 @@
+connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedUsers($this->connection);
+ $this->db = new Database($this->connection);
+ }
+}
diff --git a/tests/BugRegressionTest.php b/tests/BugRegressionTest.php
new file mode 100644
index 0000000..73aa74c
--- /dev/null
+++ b/tests/BugRegressionTest.php
@@ -0,0 +1,258 @@
+read('users', ['id', 'name'], ['id' => 2])
+ ->asAssoc()
+ ->rows();
+
+ self::assertCount(1, $rows);
+ self::assertSame('Bob', $rows[0]['name']);
+ }
+
+ /**
+ * BUG-2: UPDATE that finds rows but writes the same value used to return
+ * false (because rowCount() == 0 in MySQL — on SQLite it returns the
+ * matched rowcount, but the bug was the semantics, not the driver). Now
+ * any successful execution returns true.
+ */
+ public function test_bug2_update_returns_true_even_when_no_rows_change(): void
+ {
+ $connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedUsers($connection);
+ $db = new Database($connection);
+
+ // No rows match → "succeeded but did nothing" must still be true.
+ $result = $db->where('id', '=', 9999)
+ ->update('users', ['name' => 'Nobody']);
+
+ self::assertTrue($result);
+ self::assertSame(0, $db->affectedRows());
+ }
+
+ public function test_bug2_create_returns_true_and_exposes_affected_rows(): void
+ {
+ $connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedUsers($connection);
+ $db = new Database($connection);
+
+ $result = $db->create('users', ['name' => 'Dan', 'email' => 'dan@example.com', 'active' => 1, 'score' => 7]);
+
+ self::assertTrue($result);
+ self::assertSame(1, $db->affectedRows());
+ self::assertNotFalse($db->insertId());
+ }
+
+ /**
+ * BUG-3: Transaction failures used to be swallowed silently — the closure
+ * could throw, the user got back a bare `false`, and the original error
+ * was unreachable. Now the failure is wrapped and re-thrown.
+ */
+ public function test_bug3_transaction_failure_propagates_with_original_error(): void
+ {
+ $connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedUsers($connection);
+ $db = new Database($connection);
+
+ $caught = null;
+ try {
+ $db->transaction(function () {
+ throw new \RuntimeException('boom');
+ });
+ } catch (DatabaseException $e) {
+ $caught = $e;
+ }
+
+ self::assertNotNull($caught);
+ self::assertStringContainsString('Transaction failed', $caught->getMessage());
+ self::assertInstanceOf(\RuntimeException::class, $caught->getPrevious());
+ self::assertSame('boom', $caught->getPrevious()->getMessage());
+ self::assertFalse($db->getPDO()->inTransaction(), 'PDO must not be left in a transaction.');
+ }
+
+ public function test_bug3_transaction_retries_until_success(): void
+ {
+ $connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedUsers($connection);
+ $db = new Database($connection);
+
+ $attempts = 0;
+ $result = $db->transaction(function ($db) use (&$attempts) {
+ $attempts++;
+ if ($attempts < 3) {
+ throw new \RuntimeException('still flaky');
+ }
+ $db->create('users', ['name' => 'Eve', 'email' => 'eve@example.com', 'active' => 1, 'score' => 5]);
+ }, attempt: 3);
+
+ self::assertTrue($result);
+ self::assertSame(3, $attempts);
+ }
+
+ public function test_bug3_transaction_test_mode_rolls_back(): void
+ {
+ $connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedUsers($connection);
+ $db = new Database($connection);
+
+ $before = $db->read('users')->numRows();
+ $db->transaction(function ($db) {
+ $db->create('users', ['name' => 'X', 'email' => 'x@example.com', 'active' => 1, 'score' => 0]);
+ }, testMode: true);
+
+ // Re-open a fresh read to avoid relying on a stale DataMapper.
+ $after = $db->read('users')->numRows();
+ self::assertSame($before, $after);
+ }
+
+ public function test_bug3_transaction_rejects_invalid_attempt_count(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ $this->expectException(DatabaseInvalidArgumentException::class);
+ $db->transaction(static fn () => null, attempt: 0);
+ }
+
+ /**
+ * BUG-4: createImmutable() used to silently overwrite a previously-set
+ * facade instance. Now a second call throws unless replaceImmutable() is
+ * called explicitly.
+ */
+ public function test_bug4_create_immutable_is_actually_immutable(): void
+ {
+ DB::createImmutable(SqliteHelper::makeConnection());
+
+ $this->expectException(DatabaseException::class);
+ DB::createImmutable(SqliteHelper::makeConnection());
+ }
+
+ public function test_bug4_replace_immutable_swaps_explicitly(): void
+ {
+ $first = DB::createImmutable(SqliteHelper::makeConnection());
+ $second = DB::replaceImmutable(SqliteHelper::makeConnection());
+
+ self::assertNotSame($first, $second);
+ self::assertSame($second, DB::getDatabase());
+ }
+
+ /**
+ * BUG-5: DB used to declare a non-static __call() that could never fire.
+ * The class is now strictly static — instantiation must fail.
+ */
+ public function test_bug5_db_facade_cannot_be_instantiated(): void
+ {
+ $reflection = new \ReflectionClass(DB::class);
+ $constructor = $reflection->getConstructor();
+ self::assertNotNull($constructor);
+ self::assertTrue($constructor->isPrivate(), 'DB::__construct() must be private.');
+ }
+
+ /**
+ * BUG-6: insertId() return type used to be `int|string|false` in the
+ * interface, untyped in the impl, and `int|string` on the facade. Now
+ * uniformly `string|false`.
+ */
+ public function test_bug6_insert_id_returns_string_or_false(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->create('users', ['name' => 'F', 'email' => 'f@example.com', 'active' => 1, 'score' => 1]);
+ $id = $db->insertId();
+
+ // SQLite returns the rowid as a string per PDO contract.
+ self::assertIsString($id);
+ self::assertSame('4', $id);
+ }
+
+ /**
+ * BUG-8: __construct used to accept untyped $connection and throw a
+ * message-less exception. Now it uses a union type and the exception
+ * carries a useful message.
+ */
+ public function test_bug8_invalid_connection_throws_descriptive_exception(): void
+ {
+ $this->expectException(\TypeError::class);
+ // @phpstan-ignore-next-line — intentional misuse
+ new Database('not a connection');
+ }
+
+ /**
+ * BUG-11: update/delete used to call where($column, $value) which relied
+ * on QueryBuilder's implicit "2-arg value-shortcut". Now the call site
+ * is explicit: where($column, '=', $value).
+ */
+ public function test_bug11_update_conditions_bind_to_value_slot_explicitly(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->update('users', ['active' => 0], ['id' => 1]);
+
+ $row = $db->read('users', ['id', 'active'], ['id' => 1])->asAssoc()->row();
+ self::assertSame(0, (int) $row['active']);
+ }
+
+ /**
+ * BUG-12 (found during BUG-11's test run): CRUD methods used to leave
+ * builder state behind — a delete()'s WHERE clause would silently bleed
+ * into the next read(). The fix wipes structure + parameters after every
+ * CRUD execution (in a finally block, so even thrown queries reset).
+ */
+ public function test_bug12_builder_structure_resets_between_crud_calls(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->where('id', '=', 1)->delete('users');
+
+ // The where-clause from delete() must NOT leak into this read.
+ $rows = $db->read('users')->asAssoc()->rows();
+ self::assertCount(2, $rows, 'Builder state must reset after each CRUD call.');
+ }
+
+ public function test_bug11_delete_with_conditions(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->delete('users', ['id' => 2]);
+
+ $remaining = $db->read('users')->asAssoc()->rows();
+ self::assertCount(2, $remaining);
+ self::assertNotContains('Bob', array_column($remaining, 'name'));
+ }
+}
diff --git a/tests/CrudTest.php b/tests/CrudTest.php
new file mode 100644
index 0000000..1fe95cb
--- /dev/null
+++ b/tests/CrudTest.php
@@ -0,0 +1,156 @@
+getConnection());
+
+ $db->create('users', [
+ 'name' => 'Dan',
+ 'email' => 'dan@example.com',
+ 'active' => 1,
+ 'score' => 7,
+ ]);
+
+ self::assertSame('4', $db->insertId());
+
+ $row = $db->read('users', ['name'], ['id' => 4])->asAssoc()->row();
+ self::assertSame('Dan', $row['name']);
+ }
+
+ public function test_create_batch_inserts_multiple_rows(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->createBatch('users', [
+ ['name' => 'D', 'email' => 'd@example.com', 'active' => 1, 'score' => 1],
+ ['name' => 'E', 'email' => 'e@example.com', 'active' => 1, 'score' => 2],
+ ['name' => 'F', 'email' => 'f@example.com', 'active' => 1, 'score' => 3],
+ ]);
+
+ $rows = $db->read('users')->asAssoc()->rows();
+ self::assertCount(6, $rows);
+ }
+
+ public function test_read_with_builder_chain(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $rows = $db->select('name', 'score')
+ ->where('active', '=', 1)
+ ->orderBy('score', 'DESC')
+ ->read('users')
+ ->asAssoc()
+ ->rows();
+
+ self::assertCount(2, $rows);
+ self::assertSame('Carol', $rows[0]['name']);
+ self::assertSame('Alice', $rows[1]['name']);
+ }
+
+ public function test_read_with_limit_and_offset(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $rows = $db->orderBy('id', 'ASC')
+ ->offset(1)
+ ->limit(1)
+ ->read('users')
+ ->asAssoc()
+ ->rows();
+
+ self::assertCount(1, $rows);
+ self::assertSame('Bob', $rows[0]['name']);
+ }
+
+ public function test_update_modifies_only_matched_rows(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->update('users', ['active' => 0], ['id' => 1]);
+
+ $rows = $db->read('users', ['id', 'active'])->asAssoc()->rows();
+ $map = array_column($rows, 'active', 'id');
+
+ self::assertSame(0, (int) $map[1]);
+ self::assertSame(0, (int) $map[2]);
+ self::assertSame(1, (int) $map[3]);
+ }
+
+ public function test_update_batch_uses_case_when_per_row(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->updateBatch('id', 'users', [
+ ['id' => 1, 'score' => 100],
+ ['id' => 2, 'score' => 200],
+ ]);
+
+ $rows = $db->read('users', ['id', 'score'])->asAssoc()->rows();
+ $map = array_column($rows, 'score', 'id');
+
+ self::assertSame(100, (int) $map[1]);
+ self::assertSame(200, (int) $map[2]);
+ // The row that wasn't in the batch must keep its previous value.
+ self::assertSame(99, (int) $map[3]);
+ }
+
+ public function test_delete_removes_only_matched_rows(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->delete('users', ['id' => 2]);
+
+ $rows = $db->read('users')->asAssoc()->rows();
+ self::assertCount(2, $rows);
+ self::assertNotContains('Bob', array_column($rows, 'name'));
+ }
+
+ public function test_raw_query_passes_through_with_parameters(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $row = $db->query(
+ 'SELECT name FROM users WHERE id = :id',
+ [':id' => 3]
+ )->asAssoc()->row();
+
+ self::assertSame('Carol', $row['name']);
+ }
+
+ public function test_affected_rows_reflects_last_crud_call(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->update('users', ['active' => 0], ['active' => 1]);
+ self::assertSame(2, $db->affectedRows()); // Alice + Carol
+
+ $db->delete('users', ['id' => 99999]);
+ self::assertSame(0, $db->affectedRows());
+ }
+
+ public function test_chained_call_returns_database_not_builder(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+
+ $returned = $db->select('id')->where('id', '=', 1);
+ self::assertSame($db, $returned);
+ }
+}
diff --git a/tests/DatabaseConstructionTest.php b/tests/DatabaseConstructionTest.php
new file mode 100644
index 0000000..5681356
--- /dev/null
+++ b/tests/DatabaseConstructionTest.php
@@ -0,0 +1,124 @@
+ 'sqlite',
+ 'database' => ':memory:',
+ 'charset' => '',
+ ]);
+
+ self::assertInstanceOf(PDO::class, $db->getPDO());
+ self::assertSame('sqlite', $db->getConnection()->getDriver());
+ }
+
+ public function test_accepts_prebuilt_connection_interface(): void
+ {
+ $connection = SqliteHelper::makeConnection();
+ $db = new Database($connection);
+
+ self::assertSame($connection, $db->getConnection());
+ }
+
+ public function test_rejects_non_array_non_connection_argument_with_type_error(): void
+ {
+ $this->expectException(TypeError::class);
+ // @phpstan-ignore-next-line — intentional misuse
+ new Database('garbage');
+ }
+
+ public function test_query_builder_factory_can_be_injected(): void
+ {
+ $captured = null;
+
+ $factory = new class ($captured) implements QueryBuilderFactoryInterface {
+ /** @var mixed */
+ private $captured;
+
+ public function __construct(&$captured)
+ {
+ $this->captured = &$captured;
+ }
+
+ public function createQueryBuilder(?string $driver = null): QueryBuilderInterface
+ {
+ $this->captured = $driver;
+ return new QueryBuilder($driver);
+ }
+ };
+
+ new Database(SqliteHelper::makeConnection(), $factory);
+ self::assertSame('sqlite', $captured);
+ }
+
+ public function test_with_fresh_builder_isolates_state(): void
+ {
+ $connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedUsers($connection);
+ $db = new Database($connection);
+
+ $db->select('id')->where('id', '=', 1);
+ $sibling = $db->withFreshBuilder();
+
+ self::assertNotSame($db, $sibling);
+ self::assertSame($db->getConnection(), $sibling->getConnection(), 'Connection must be shared.');
+
+ // The sibling's builder is empty: SELECT * with no WHERE.
+ $rows = $sibling->read('users')->asAssoc()->rows();
+ self::assertCount(3, $rows);
+ }
+
+ public function test_legacy_builder_alias_still_works(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ $sibling = $db->builder();
+
+ self::assertNotSame($db, $sibling);
+ self::assertSame($db->getConnection(), $sibling->getConnection());
+ }
+
+ public function test_unknown_method_throws_descriptive_exception(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+
+ $this->expectException(\InitORM\Database\Exceptions\DatabaseException::class);
+ $this->expectExceptionMessageMatches('/QueryBuilderInterface::someBogusMethod/');
+
+ // @phpstan-ignore-next-line — intentional misuse
+ $db->someBogusMethod();
+ }
+
+ public function test_clone_creates_independent_builder(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ $copy = clone $db;
+
+ $db->select('a');
+ $copy->select('b');
+
+ $dbSelect = $db->exportQB()['select'];
+ $copySelect = $copy->exportQB()['select'];
+
+ self::assertCount(1, $dbSelect);
+ self::assertCount(1, $copySelect);
+ self::assertNotSame($dbSelect, $copySelect, 'Cloned builder must hold independent state.');
+ self::assertStringContainsString('a', $dbSelect[0]);
+ self::assertStringContainsString('b', $copySelect[0]);
+ }
+}
diff --git a/tests/FacadeTest.php b/tests/FacadeTest.php
new file mode 100644
index 0000000..db8391e
--- /dev/null
+++ b/tests/FacadeTest.php
@@ -0,0 +1,80 @@
+expectException(DatabaseException::class);
+ DB::getDatabase();
+ }
+
+ public function test_create_immutable_then_get(): void
+ {
+ $instance = DB::createImmutable(SqliteHelper::makeConnection());
+ self::assertSame($instance, DB::getDatabase());
+ self::assertInstanceOf(DatabaseInterface::class, $instance);
+ }
+
+ public function test_connect_does_not_touch_facade_slot(): void
+ {
+ $first = DB::connect(SqliteHelper::makeConnection());
+ self::assertInstanceOf(DatabaseInterface::class, $first);
+
+ // The facade slot is still empty since connect() doesn't set it.
+ $this->expectException(DatabaseException::class);
+ DB::getDatabase();
+ }
+
+ public function test_replace_immutable_accepts_a_database_instance(): void
+ {
+ $custom = SqliteHelper::makeDatabase();
+ $result = DB::replaceImmutable($custom);
+
+ self::assertSame($custom, $result);
+ self::assertSame($custom, DB::getDatabase());
+ }
+
+ public function test_replace_immutable_with_null_clears_facade(): void
+ {
+ DB::createImmutable(SqliteHelper::makeConnection());
+ self::assertNull(DB::replaceImmutable(null));
+
+ $this->expectException(DatabaseException::class);
+ DB::getDatabase();
+ }
+
+ public function test_static_call_forwards_to_underlying_database(): void
+ {
+ $connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedUsers($connection);
+ DB::createImmutable($connection);
+
+ $rows = DB::read('users')->asAssoc()->rows();
+ self::assertCount(3, $rows);
+ }
+
+ public function test_facade_constructor_is_private(): void
+ {
+ $reflection = new \ReflectionClass(DB::class);
+ $constructor = $reflection->getConstructor();
+ self::assertNotNull($constructor);
+ self::assertTrue($constructor->isPrivate());
+ }
+}
diff --git a/tests/QueryLogTest.php b/tests/QueryLogTest.php
new file mode 100644
index 0000000..70ad4a0
--- /dev/null
+++ b/tests/QueryLogTest.php
@@ -0,0 +1,58 @@
+getConnection());
+
+ $db->read('users');
+ self::assertSame([], $db->getQueryLogs());
+ }
+
+ public function test_enabling_query_log_records_each_query(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->enableQueryLog();
+ $db->read('users', ['id'], ['id' => 1]);
+ $db->read('users', ['id'], ['id' => 2]);
+
+ $logs = $db->getQueryLogs();
+ self::assertCount(2, $logs);
+ self::assertArrayHasKey('query', $logs[0]);
+ self::assertArrayHasKey('args', $logs[0]);
+ self::assertArrayHasKey('timer', $logs[0]);
+ self::assertIsFloat($logs[0]['timer']);
+ self::assertStringStartsWith('SELECT', $logs[0]['query']);
+ }
+
+ public function test_disabling_query_log_stops_recording(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $db->enableQueryLog();
+ $db->read('users');
+ $db->disableQueryLog();
+ $db->read('users');
+
+ self::assertCount(1, $db->getQueryLogs(), 'Only the read while logging was enabled should be recorded.');
+ }
+
+ public function test_enable_disable_return_database_for_chaining(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ self::assertSame($db, $db->enableQueryLog());
+ self::assertSame($db, $db->disableQueryLog());
+ }
+}
diff --git a/tests/Support/SqliteHelper.php b/tests/Support/SqliteHelper.php
new file mode 100644
index 0000000..e235601
--- /dev/null
+++ b/tests/Support/SqliteHelper.php
@@ -0,0 +1,67 @@
+ $overrides
+ */
+ public static function makeConnection(array $overrides = []): ConnectionInterface
+ {
+ return new Connection(array_merge([
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'charset' => '',
+ ], $overrides));
+ }
+
+ public static function makeDatabase(array $overrides = []): DatabaseInterface
+ {
+ return new Database(self::makeConnection($overrides));
+ }
+
+ public static function seedUsers(ConnectionInterface $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
+ )'
+ );
+ $pdo->exec(
+ "INSERT INTO users (name, email, active, score) VALUES
+ ('Alice', 'alice@example.com', 1, 42),
+ ('Bob', 'bob@example.com', 0, 13),
+ ('Carol', 'carol@example.com', 1, 99)"
+ );
+ }
+
+ public static function seedPosts(ConnectionInterface $connection): void
+ {
+ $pdo = $connection->getPDO();
+ $pdo->exec(
+ 'CREATE TABLE posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT
+ )'
+ );
+ }
+}
diff --git a/tests/TransactionTest.php b/tests/TransactionTest.php
new file mode 100644
index 0000000..6c597ad
--- /dev/null
+++ b/tests/TransactionTest.php
@@ -0,0 +1,85 @@
+getConnection());
+
+ $result = $db->transaction(function ($db) {
+ $db->create('users', ['name' => 'T', 'email' => 't@example.com', 'active' => 1, 'score' => 1]);
+ });
+
+ self::assertTrue($result);
+ self::assertCount(4, $db->read('users')->asAssoc()->rows());
+ }
+
+ public function test_exception_rolls_back_and_wraps_original(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ try {
+ $db->transaction(function ($db) {
+ $db->create('users', ['name' => 'X', 'email' => 'x@example.com', 'active' => 1, 'score' => 0]);
+ throw new RuntimeException('rollback me');
+ });
+ self::fail('Expected DatabaseException');
+ } catch (DatabaseException $e) {
+ self::assertSame('rollback me', $e->getPrevious()?->getMessage());
+ }
+
+ self::assertCount(3, $db->read('users')->asAssoc()->rows(), 'INSERT inside the failed tx must have rolled back.');
+ }
+
+ public function test_nested_transaction_call_throws(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $caught = null;
+ try {
+ $db->transaction(function ($db) {
+ // Trying to open another transaction on the same PDO while
+ // one is already in flight must fail clearly.
+ $db->transaction(static fn () => null);
+ });
+ } catch (DatabaseException $e) {
+ $caught = $e;
+ }
+
+ self::assertNotNull($caught);
+ self::assertStringContainsString('Transaction failed', $caught->getMessage());
+ }
+
+ public function test_invalid_attempt_throws_invalid_argument(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ $this->expectException(DatabaseInvalidArgumentException::class);
+ $db->transaction(static fn () => null, attempt: -1);
+ }
+
+ public function test_test_mode_rolls_back_even_on_success(): void
+ {
+ $db = SqliteHelper::makeDatabase();
+ SqliteHelper::seedUsers($db->getConnection());
+
+ $result = $db->transaction(function ($db) {
+ $db->create('users', ['name' => 'Z', 'email' => 'z@example.com', 'active' => 1, 'score' => 0]);
+ }, testMode: true);
+
+ self::assertTrue($result);
+ self::assertCount(3, $db->read('users')->asAssoc()->rows(), 'testMode must roll back even though the closure succeeded.');
+ }
+}