diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..4640e2b
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,18 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
+
+[composer.json]
+indent_size = 4
diff --git a/.github/workflows/composer-validate.yml b/.github/workflows/composer-validate.yml
new file mode 100644
index 0000000..d89a0c3
--- /dev/null
+++ b/.github/workflows/composer-validate.yml
@@ -0,0 +1,31 @@
+name: Validate composer.json
+
+on:
+ push:
+ paths:
+ - 'composer.json'
+ - 'composer.lock'
+ pull_request:
+ paths:
+ - 'composer.json'
+ - 'composer.lock'
+
+permissions:
+ contents: read
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.2"
+ tools: composer:v2
+
+ - name: Validate composer.json
+ run: composer validate --strict
diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml
new file mode 100644
index 0000000..02cb9ce
--- /dev/null
+++ b/.github/workflows/phpcs.yml
@@ -0,0 +1,40 @@
+name: PHP_CodeSniffer
+
+on:
+ push:
+ branches: ["master", "main"]
+ pull_request:
+ branches: ["master", "main"]
+
+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..de0ebf6
--- /dev/null
+++ b/.github/workflows/phpstan.yml
@@ -0,0 +1,40 @@
+name: PHPStan
+
+on:
+ push:
+ branches: ["master", "main"]
+ pull_request:
+ branches: ["master", "main"]
+
+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..c1b4280
--- /dev/null
+++ b/.github/workflows/phpunit.yml
@@ -0,0 +1,54 @@
+name: PHPUnit
+
+on:
+ push:
+ branches: ["master", "main"]
+ pull_request:
+ branches: ["master", "main"]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: PHP ${{ matrix.php-version }}
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version: ["8.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
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 2a525c7..6b5e94b 100644
--- a/README.md
+++ b/README.md
@@ -1,173 +1,307 @@
-# InitORM
-
-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/orm) [](https://packagist.org/packages/initorm/orm) [](https://packagist.org/packages/initorm/orm) [](https://packagist.org/packages/initorm/orm) [](https://packagist.org/packages/initorm/orm)
-
-## 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/orm
-```
-
-## Usage
-
-### Model and Entity
-
-Model and Entity; are two common concepts used in database abstraction. To explain these two concepts in the roughest way;
-
-- **Model :** Each model is a class that represents a table in the database.
-- **Entity :** Entity is a class that represents a single row of data.
-
-The most basic example of a model class would look like this.
-
-```php
-namespace App\Model;
-
-class Posts extends \InitORM\ORM\Model
-{
-
- /**
- * If your model will use a connection other than your global connection, provide connection information.
- * @var array|null
Default : NULL
- */
- protected array $credentials = [
- 'dsn' => '',
- 'username' => 'root',
- 'password' => '',
- 'charset' => 'utf8mb4',
- 'collation' => 'utf8mb4_unicode_ci',
- ];
-
- /**
- * If not specified, \InitORM\ORM\Entity::class is used by default.
- *
- * @var string<\InitORM\ORM\Entity>
- */
- protected $entity = \App\Entities\PostEntity::class;
-
- /**
- * If not specified, the name of your model class is used.
- *
- * @var string
- */
- protected string $schema = 'posts';
-
- /**
- * The name of the PRIMARY KEY column.
- *
- * @var string
- */
- protected string $schemaId = 'id';
-
- /**
- * Specify FALSE if you want the data to be permanently deleted.
- *
- * @var bool
- */
- protected bool $useSoftDeletes = true;
-
- /**
- * Column name to hold the creation time of the data.
- *
- * @var string|null
- */
- protected ?string $createdField = 'created_at';
-
- /**
- * The column name to hold the last time the data was updated.
- *
- * @var string|null
- */
- protected ?string $updatedField = 'updated_at';
-
- /**
- * Column name to keep deletion time if $useSoftDeletes is active.
- *
- * @var string|null
- */
- protected ?string $deletedField = 'deleted_at';
-
- protected bool $readable = true;
-
- protected bool $writable = true;
-
- protected bool $deletable = true;
-
- protected bool $updatable = true;
-
-}
-```
-
-The most basic example of a entity class would look like this.
-
-```php
-namespace App\Entities;
-
-class PostEntity extends \InitORM\ORM\Entity
-{
- /**
- * An example of a getter method for the "post_title" column.
- *
- * Usage :
- * echo $entity->post_title;
- */
- public function getPostTitleAttribute($title)
- {
- return strtoupper($title);
- }
-
- /**
- * An example of a setter method for the "post_title" column.
- *
- * Usage :
- * $entity->post_title = 'New Post Title';
- */
- public function setPostTitleAttribute($title)
- {
- $this->post_title = strtolower($title);
- }
-
-}
-```
-
-## 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 ORM
+
+A lightweight, PDO-based ORM. Each subclass of [`Model`](src/Model.php) maps a table to an [`Entity`](src/Entity.php) class and exposes Active-Record-style CRUD on top of [`initorm/database`](https://github.com/InitORM/Database). Entities support per-column accessor and mutator hooks (Laravel-style), and models ship with optional timestamp columns, soft deletes, and per-operation permission gates.
+
+[](https://packagist.org/packages/initorm/orm)
+[](https://packagist.org/packages/initorm/orm)
+[](https://packagist.org/packages/initorm/orm)
+[](https://packagist.org/packages/initorm/orm)
+[](https://github.com/InitORM/ORM/actions/workflows/phpunit.yml)
+[](https://github.com/InitORM/ORM/actions/workflows/phpstan.yml)
+[](https://github.com/InitORM/ORM/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
+
+The full query-builder dialect support comes through `initorm/database`: **MySQL/MariaDB**, **PostgreSQL**, and **SQLite** ship with dialect-aware identifier quoting; any other PDO driver works through a no-escape generic driver.
+
+## Installation
+
+```bash
+composer require initorm/orm
+```
+
+`initorm/orm` pulls in `initorm/database`, `initorm/dbal`, and `initorm/query-builder` transitively — you do not need to require them yourself.
+
+---
+
+## Quick start
+
+```php
+ 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'username' => 'app',
+ 'password' => 'secret',
+]);
+```
+
+Define a model:
+
+```php
+namespace App\Model;
+
+class Posts extends \InitORM\ORM\Model
+{
+ protected string $schema = 'posts';
+ protected string $schemaId = 'id';
+
+ protected bool $useSoftDeletes = true;
+ protected ?string $createdField = 'created_at';
+ protected ?string $updatedField = 'updated_at';
+ protected ?string $deletedField = 'deleted_at';
+
+ protected string $entity = \App\Entity\PostEntity::class;
+}
+```
+
+Define an entity:
+
+```php
+namespace App\Entity;
+
+class PostEntity extends \InitORM\ORM\Entity
+{
+ public function getTitleAttribute(mixed $value): mixed
+ {
+ return is_string($value) ? ucwords($value) : $value;
+ }
+
+ public function setTitleAttribute(mixed $value): void
+ {
+ // ALWAYS use setAttribute() inside a mutator —
+ // $this->title = ... bypasses __set and creates a dynamic property.
+ $this->setAttribute('title', is_string($value) ? trim($value) : $value);
+ }
+}
+```
+
+Use the model:
+
+```php
+use App\Model\Posts;
+
+$posts = new Posts();
+
+// Create
+$posts->create(['title' => 'My First Post', 'body' => 'Hello world']);
+
+// Read (returns a DataMapper hydrating PostEntity instances)
+foreach ($posts->read()->rows() as $entity) {
+ echo $entity->title; // accessor runs here
+}
+
+// Update by primary key (lifted out of $set into a WHERE)
+$posts->update(['id' => 5, 'title' => 'Edited']);
+
+// Soft delete (sets deleted_at), then permanently purge
+$posts->delete(['id' => 5]);
+$posts->delete(['id' => 5], purge: true);
+```
+
+---
+
+## How it fits together
+
+```
+QueryBuilder ──► Database ──► ORM (this package)
+DBAL ──► Database
+```
+
+A `Model` holds a [`DatabaseInterface`](https://github.com/InitORM/Database/blob/master/src/Interfaces/DatabaseInterface.php) and forwards unknown calls to it via `__call`. The Database, in turn, forwards builder calls (`where`, `select`, `orderBy`, …) to the underlying query builder. Chainable calls re-wrap at every boundary, so this all stays fluent on the Model:
+
+```php
+$entities = $posts
+ ->where('status', '=', 'published')
+ ->orderBy('id', 'DESC')
+ ->limit(10)
+ ->read()
+ ->rows();
+```
+
+The full query-builder surface (~100 methods including joins, group/having, sub-queries, LIKE family, BETWEEN, IN, raw expressions) is documented in [`initorm/query-builder`](https://github.com/InitORM/QueryBuilder) and [`initorm/database`](https://github.com/InitORM/Database).
+
+---
+
+## Configuration reference
+
+These protected properties shape a model's behaviour. All are optional with sensible defaults.
+
+| Property | Type | Default | Notes |
+| -------------------- | ------------------------------- | -------------- | -------------------------------------------------------------------------------------------------- |
+| `$schema` | `string` | _(derived)_ | Table name. When unset, auto-derived from the short class name via snake_case conversion. |
+| `$schemaId` | `string` | `'id'` | Primary-key column. Lifted out of `update()`'s `$set` into a WHERE; used by `save()` to pick CRUD. |
+| `$entity` | `class-string` | `Entity::class`| Class used to hydrate `read()` rows. |
+| `$credentials` | `array\|null` | `null` | Standalone connection credentials; null binds to the shared `DB` facade. |
+| `$writable` | `bool` | `true` | When false, `create()`/`createBatch()` throw `WritableException`. |
+| `$readable` | `bool` | `true` | When false, `read()` throws `ReadableException`. |
+| `$updatable` | `bool` | `true` | When false, `update()`/`updateBatch()` throw `UpdatableException`. |
+| `$deletable` | `bool` | `true` | When false, `delete()` throws `DeletableException`. |
+| `$createdField` | `string\|null` | `null` | Auto-filled with the current timestamp on every create. Disabled when null. |
+| `$updatedField` | `string\|null` | `null` | Auto-filled with the current timestamp on every update. Disabled when null. |
+| `$useSoftDeletes` | `bool` | `false` | When true, `delete()` sets `$deletedField` instead of issuing a DELETE. Requires `$deletedField`. |
+| `$deletedField` | `string\|null` | `null` | Soft-delete marker column. Must be set when `$useSoftDeletes` is true (enforced at construction). |
+| `$timestampFormat` | `string` | `'Y-m-d H:i:s'`| `date()` format used for created / updated / deleted columns. |
+
+---
+
+## Soft deletes
+
+When `$useSoftDeletes = true`:
+
+- `read()` automatically filters to rows where `$deletedField IS NULL`.
+- `delete()` sets `$deletedField` to the current timestamp instead of issuing a DELETE.
+- Pass `purge: true` to bypass soft-delete and remove the row for real:
+ ```php
+ $posts->delete(['id' => 5], purge: true);
+ ```
+- Use `onlyDeleted()` to read soft-deleted rows on the next `read()`:
+ ```php
+ foreach ($posts->onlyDeleted()->read()->rows() as $deleted) {
+ // …
+ }
+ ```
+ The flag is consumed by the next read and reverts afterwards.
+
+`update()` and `updateBatch()` automatically add `$deletedField IS NULL` to avoid resurrecting soft-deleted rows.
+
+---
+
+## Entities, accessors, and mutators
+
+`Entity` stores values in an internal attribute bag (`$attributes`) and exposes them through the magic `__get` / `__set` accessors. For column `post_title`, you can define:
+
+```php
+class PostEntity extends \InitORM\ORM\Entity
+{
+ public function getPostTitleAttribute(mixed $value): mixed
+ {
+ return ucwords((string) $value);
+ }
+
+ public function setPostTitleAttribute(mixed $value): void
+ {
+ $this->setAttribute('post_title', strtolower((string) $value));
+ }
+}
+```
+
+- **Accessor** receives the stored attribute value as its single argument.
+- **Mutator** must write back via `$this->setAttribute('post_title', …)`. Plain `$this->post_title = …` from inside a class method bypasses `__set` and creates a dynamic property (deprecated in PHP 8.2+, fatal in a future PHP version), so the value would never reach the attribute bag.
+- `toArray()` / `getAttributes()` return the raw attribute bag.
+- `getOriginal()` returns a snapshot captured at construction time; call `syncOriginal()` to refresh it after a save.
+
+---
+
+## Permission gates
+
+Each operation is gated by a flag — flip any to `false` and the matching typed exception fires:
+
+```php
+class ReadOnlyConfig extends \InitORM\ORM\Model
+{
+ protected string $schema = 'configuration';
+ protected bool $writable = false;
+ protected bool $updatable = false;
+ protected bool $deletable = false;
+}
+
+(new ReadOnlyConfig())->create([...]); // throws WritableException
+```
+
+All four — `WritableException`, `ReadableException`, `UpdatableException`, `DeletableException` — extend `ModelException`, so a single catch handles all of them.
+
+---
+
+## Multiple connections
+
+Models bind to the shared `DB` facade by default. Set `$credentials` on a subclass to give it its own connection:
+
+```php
+class ReportsModel extends \InitORM\ORM\Model
+{
+ protected string $schema = 'events';
+
+ protected ?array $credentials = [
+ 'dsn' => 'pgsql:host=reports.internal;dbname=reports',
+ 'username' => 'reports_ro',
+ 'password' => '…',
+ 'driver' => 'pgsql',
+ ];
+}
+```
+
+`$credentials` is passed through to [`InitORM\Database\Facade\DB::connect()`](https://github.com/InitORM/Database/blob/master/src/Facade/DB.php), which builds a fresh `Database` (and underlying connection) without touching the shared facade slot.
+
+---
+
+## Testing
+
+The library ships with a comprehensive PHPUnit 10 suite that exercises the model against an in-memory SQLite database. Patterns for testing your own models live in [`tests/Support/AbstractModelTestCase.php`](tests/Support/AbstractModelTestCase.php).
+
+Run the full quality suite locally:
+
+```bash
+composer qa # phpcs + phpstan + phpunit
+```
+
+Individual targets:
+
+```bash
+composer test # phpunit
+composer cs # phpcs
+composer cs-fix # phpcbf
+composer stan # phpstan analyse
+```
+
+---
+
+## Documentation
+
+Deeper, code-first guides live under [`docs/`](docs/):
+
+- [`01-getting-started.md`](docs/01-getting-started.md) — install, bootstrap, the layered architecture.
+- [`02-defining-models.md`](docs/02-defining-models.md) — every property, plus auto-schema derivation.
+- [`03-entities.md`](docs/03-entities.md) — accessors, mutators, attribute bag, the `setAttribute` rule.
+- [`04-crud-basics.md`](docs/04-crud-basics.md) — `create`, `read`, `update`, `delete`, batch variants.
+- [`05-soft-deletes.md`](docs/05-soft-deletes.md) — `useSoftDeletes`, `onlyDeleted`, `ignoreDeleted`, `purge`.
+- [`06-timestamps.md`](docs/06-timestamps.md) — auto-filled `created_at` / `updated_at` / `deleted_at`.
+- [`07-permission-gates.md`](docs/07-permission-gates.md) — `$writable` / `$readable` / `$updatable` / `$deletable`.
+- [`08-extending-the-builder.md`](docs/08-extending-the-builder.md) — accessing the full query-builder API through the Model.
+- [`09-multiple-connections.md`](docs/09-multiple-connections.md) — `$credentials`, the `DB` facade, secondary connections.
+- [`10-testing-models.md`](docs/10-testing-models.md) — how the package's own suite is wired and how to mirror it.
+
+---
+
+## 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/) — SQLite in-memory, fast, dependency-free).
+3. Run the full quality suite locally:
+ ```bash
+ composer qa
+ ```
+4. Open a PR — CI runs 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 214250e..578431b 100644
--- a/composer.json
+++ b/composer.json
@@ -1,24 +1,75 @@
{
"name": "initorm/orm",
- "description": "InitORM ORM",
- "keywords": ["php", "php8", "pdo", "database", "orm"],
+ "description": "Lightweight, PDO-based ORM with Active Record-style models and entity accessors/mutators. Built on top of initorm/database, initorm/dbal and initorm/query-builder.",
"type": "library",
"license": "MIT",
- "autoload": {
- "psr-4": {
- "InitORM\\ORM\\": "src/"
- }
+ "keywords": [
+ "orm",
+ "model",
+ "entity",
+ "active-record",
+ "soft-delete",
+ "pdo",
+ "mysql",
+ "pgsql",
+ "sqlite",
+ "initorm"
+ ],
+ "homepage": "https://github.com/InitORM/ORM",
+ "support": {
+ "issues": "https://github.com/InitORM/ORM/issues",
+ "source": "https://github.com/InitORM/ORM",
+ "docs": "https://github.com/InitORM/ORM/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": ">=8.0",
+ "php": "^8.1",
"ext-pdo": "*",
- "initorm/database": "^1.0"
+ "initorm/database": "^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\\ORM\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "InitORM\\ORM\\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..ca4459a
--- /dev/null
+++ b/docs/01-getting-started.md
@@ -0,0 +1,110 @@
+# 01 — Getting started
+
+`initorm/orm` is the topmost layer of the InitORM stack. Each layer adds one concern; you can drop down a layer whenever you want raw access.
+
+```
+QueryBuilder ──► Database ──► ORM (this package)
+DBAL ──► Database
+```
+
+| Layer | Job |
+| --------------------- | ---------------------------------------------------------------- |
+| `initorm/query-builder` | Pure SQL string assembly + parameter binding (no I/O). |
+| `initorm/dbal` | PDO lifecycle + a fluent result mapper (`asAssoc`, `asClass`). |
+| `initorm/database` | Glues a connection to a builder; exposes CRUD + transactions. |
+| `initorm/orm` (you) | Active-Record-style models + entities. |
+
+A `Model` holds a `DatabaseInterface`. Unknown method calls are forwarded to it (and the Database forwards builder calls to the query builder). Chainable calls re-wrap at every boundary, so the model is the natural root of a fluent chain.
+
+---
+
+## Install
+
+```bash
+composer require initorm/orm
+```
+
+`initorm/orm` declares `php: ^8.1`, `ext-pdo: *`, and `initorm/database: ^2.0` — the other two layers come transitively.
+
+---
+
+## Bootstrap
+
+Models bind to the shared `DB` facade by default. Configure it once at boot:
+
+```php
+require_once 'vendor/autoload.php';
+
+use InitORM\Database\Facade\DB;
+
+DB::createImmutable([
+ 'dsn' => 'mysql:host=localhost;dbname=app;charset=utf8mb4',
+ 'username' => 'app',
+ 'password' => 'secret',
+]);
+```
+
+`createImmutable()` throws if called a second time. To swap the slot explicitly (rare, mostly for tests) call `DB::replaceImmutable($next)`.
+
+For SQLite — useful in tests:
+
+```php
+DB::createImmutable([
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ 'charset' => '',
+]);
+```
+
+The configuration array is forwarded verbatim to `initorm/database`. See the [Database configuration reference](https://github.com/InitORM/Database/blob/master/README.md#configuration-reference) for every key.
+
+---
+
+## First model + entity
+
+```php
+namespace App\Model;
+
+use InitORM\ORM\Model;
+
+class Posts extends Model
+{
+ protected string $schema = 'posts';
+ protected string $schemaId = 'id';
+}
+```
+
+```php
+namespace App\Entity;
+
+use InitORM\ORM\Entity;
+
+class PostEntity extends Entity
+{
+}
+```
+
+Wire the entity into the model:
+
+```php
+class Posts extends Model
+{
+ protected string $schema = 'posts';
+ protected string $entity = \App\Entity\PostEntity::class;
+}
+```
+
+Use it:
+
+```php
+$posts = new \App\Model\Posts();
+
+$posts->create(['title' => 'Hello', 'body' => 'World']);
+
+foreach ($posts->read()->rows() as $row) {
+ var_dump($row instanceof \App\Entity\PostEntity); // bool(true)
+ echo $row->title, PHP_EOL;
+}
+```
+
+That is the entire surface for the simplest case. Subsequent docs add timestamps, soft deletes, accessors / mutators, and the builder forwarding contract.
diff --git a/docs/02-defining-models.md b/docs/02-defining-models.md
new file mode 100644
index 0000000..0471d7b
--- /dev/null
+++ b/docs/02-defining-models.md
@@ -0,0 +1,97 @@
+# 02 — Defining models
+
+A model is a subclass of [`InitORM\ORM\Model`](../src/Model.php). Each protected property below customises one aspect of its behaviour; every one is optional.
+
+---
+
+## Properties
+
+| Property | Type | Default |
+| -------------------- | ------------------------------- | -------------- |
+| `$schema` | `string` | _(derived)_ |
+| `$schemaId` | `string` | `'id'` |
+| `$entity` | `class-string` | `Entity::class`|
+| `$credentials` | `array\|null` | `null` |
+| `$writable` | `bool` | `true` |
+| `$readable` | `bool` | `true` |
+| `$updatable` | `bool` | `true` |
+| `$deletable` | `bool` | `true` |
+| `$createdField` | `string\|null` | `null` |
+| `$updatedField` | `string\|null` | `null` |
+| `$useSoftDeletes` | `bool` | `false` |
+| `$deletedField` | `string\|null` | `null` |
+| `$timestampFormat` | `string` | `'Y-m-d H:i:s'`|
+
+### `$schema`
+
+The backing table name. When the property is **not declared** (or left unset), the constructor derives it from the subclass short name via `Helper::camelCaseToSnakeCase()`:
+
+```php
+class PostCategory extends \InitORM\ORM\Model {}
+
+(new PostCategory())->getSchema(); // 'post_category'
+```
+
+Conversion rules:
+
+| Class short name | Derived schema |
+| ---------------------- | ------------------------ |
+| `Posts` | `posts` |
+| `PostCategory` | `post_category` |
+| `PostCategoryTag` | `post_category_tag` |
+| `XMLParser` | `xml_parser` |
+| `HTTPRequest` | `http_request` |
+
+For anything more exotic, set `$schema` explicitly.
+
+### `$schemaId`
+
+Primary-key column. Used in two places:
+
+- `update()` lifts the PK out of the `$set` array into a WHERE clause, so it is never overwritten.
+- `save(Entity)` reads it to decide whether to insert or update.
+
+### `$entity`
+
+Class used by `read()` to hydrate rows. Defaults to the bare `Entity` class. Any class with a no-arg-compatible constructor works (PDO's `FETCH_CLASS` is used under the hood), but the conventional choice is a subclass of `Entity`.
+
+### `$credentials`
+
+Standalone connection credentials passed straight to `DB::connect()`. When `null`, the model binds to the shared `DB::getDatabase()` facade. See [09 — Multiple connections](09-multiple-connections.md).
+
+### Permission gates
+
+`$writable`, `$readable`, `$updatable`, `$deletable` — set any of these to `false` and the matching operation throws a typed exception. See [07 — Permission gates](07-permission-gates.md).
+
+### Timestamps
+
+`$createdField`, `$updatedField`, `$deletedField`, `$timestampFormat` — see [06 — Timestamps](06-timestamps.md).
+
+### Soft deletes
+
+`$useSoftDeletes`, `$deletedField` — see [05 — Soft deletes](05-soft-deletes.md).
+
+---
+
+## Construction
+
+The constructor:
+
+1. Auto-derives `$schema` if it was not set.
+2. Validates the soft-delete invariant — `$useSoftDeletes = true` without a `$deletedField` raises `ModelException`.
+3. Acquires a `DatabaseInterface` (the `DB` facade or a fresh standalone connection via `$credentials`).
+
+Subclasses overriding `__construct` should call `parent::__construct()` after they have set any required properties:
+
+```php
+class Posts extends \InitORM\ORM\Model
+{
+ public function __construct(string $schema = 'posts')
+ {
+ $this->schema = $schema;
+ parent::__construct();
+ }
+}
+```
+
+That said — overriding the constructor is rare. Configuring the model through declared properties is the conventional path because it composes cleanly with PSR-4 autoloading.
diff --git a/docs/03-entities.md b/docs/03-entities.md
new file mode 100644
index 0000000..4d6b6ce
--- /dev/null
+++ b/docs/03-entities.md
@@ -0,0 +1,135 @@
+# 03 — Entities
+
+An entity is a typed row container. The reference implementation is [`InitORM\ORM\Entity`](../src/Entity.php) — values live in an internal `$attributes` array, with optional per-column accessor / mutator hooks for transforming values on read / write.
+
+---
+
+## The attribute bag
+
+```php
+$entity = new \InitORM\ORM\Entity(['title' => 'Hello', 'body' => 'World']);
+
+$entity->toArray(); // ['title' => 'Hello', 'body' => 'World']
+$entity->getAttributes(); // same
+$entity->title; // 'Hello'
+$entity->title = 'Updated';
+$entity->title; // 'Updated'
+isset($entity->title); // true
+unset($entity->title);
+isset($entity->title); // false
+```
+
+Reads and writes go through `__get` / `__set`, which check for an accessor / mutator method first and fall back to the attribute bag.
+
+---
+
+## Accessors
+
+An accessor is a method named `get{Column}Attribute(mixed $value)`. The column name is the PascalCase form of the snake_case column. `$value` is the current stored value (or `null` if the attribute is absent).
+
+```php
+class PostEntity extends \InitORM\ORM\Entity
+{
+ public function getTitleAttribute(mixed $value): mixed
+ {
+ return is_string($value) ? strtoupper($value) : $value;
+ }
+}
+
+$entity = new PostEntity(['title' => 'hello']);
+$entity->title; // 'HELLO' — transformed by the accessor
+$entity->getAttribute('title'); // 'hello' — bypasses the accessor
+```
+
+---
+
+## Mutators
+
+A mutator is a method named `set{Column}Attribute(mixed $value)`. It is invoked with the incoming value when the column is written.
+
+**The mutator body must write back via `setAttribute()`** — `$this->title = …` from inside a class method bypasses `__set` and creates a dynamic property, so the value never reaches the attribute bag (and PHP 8.2+ raises a deprecation; a future PHP version will make it fatal).
+
+```php
+class PostEntity extends \InitORM\ORM\Entity
+{
+ public function setTitleAttribute(mixed $value): void
+ {
+ $this->setAttribute('title', is_string($value) ? trim($value) : $value);
+ }
+}
+
+$entity = new PostEntity();
+$entity->title = ' hello ';
+$entity->getAttribute('title'); // 'hello' — mutator stripped the whitespace
+```
+
+A mutator + accessor pair is common — the mutator normalises on write, the accessor presents on read:
+
+```php
+class UserEntity extends \InitORM\ORM\Entity
+{
+ public function setEmailAttribute(mixed $value): void
+ {
+ $this->setAttribute('email', is_string($value) ? strtolower(trim($value)) : $value);
+ }
+
+ public function getEmailAttribute(mixed $value): mixed
+ {
+ // Stored lower-cased; presented as-is to the caller.
+ return $value;
+ }
+}
+```
+
+---
+
+## `setAttribute` / `getAttribute`
+
+The helper methods bypass the magic hooks entirely. Use them:
+
+- Inside a mutator body, to write back without re-entering the mutator.
+- Inside an accessor body, to read a peer column without re-entering its accessor.
+- In tests, to assert what was actually stored vs. what the accessor presents.
+
+```php
+$entity->setAttribute('email', 'me@example.com');
+$entity->getAttribute('email'); // 'me@example.com'
+```
+
+---
+
+## Dirty tracking baseline
+
+Each entity captures the construction-time attribute bag as the **original** snapshot. Mutations after construction do not change it.
+
+```php
+$entity = new \InitORM\ORM\Entity(['title' => 'Hello']);
+$entity->title = 'Edited';
+
+$entity->getOriginal(); // ['title' => 'Hello']
+$entity->getAttributes(); // ['title' => 'Edited']
+```
+
+Call `syncOriginal()` to overwrite the snapshot with the current values — for example, after persisting via `Model::save()`:
+
+```php
+$model = new \App\Model\Posts();
+$model->save($entity);
+$entity->syncOriginal(); // entity is "clean" again
+```
+
+Diffing the two arrays gives a simple is-dirty / changed-columns check — the package intentionally does not bake this into the interface so consumers can pick their own semantics.
+
+---
+
+## Bypassing the magic completely
+
+`__call` on the base `Entity` provides a default implementation of `get{Column}Attribute()` / `set{Column}Attribute()` when the subclass has not declared one:
+
+```php
+$entity = new \InitORM\ORM\Entity();
+$entity->setPostTitleAttribute('Hello'); // routes to $attributes['post_title']
+$entity->getPostTitleAttribute(); // returns 'Hello'
+```
+
+This is what makes the magic property accessors round-trip with no boilerplate.
diff --git a/docs/04-crud-basics.md b/docs/04-crud-basics.md
new file mode 100644
index 0000000..12b0d39
--- /dev/null
+++ b/docs/04-crud-basics.md
@@ -0,0 +1,162 @@
+# 04 — CRUD basics
+
+Every model exposes six CRUD methods. They all return `bool true` on successful execution and throw on failure — read [`affectedRows()`](#how-many-rows-changed) when you also need the row count.
+
+| Method | Job |
+| ----------------- | -------------------------------------------------------- |
+| `create($set)` | Insert one row. |
+| `createBatch($set)` | Insert many rows in a single statement. |
+| `read($selectors, $conditions)` | SELECT, hydrated as `$entity` instances. |
+| `update($set, $conditions)` | UPDATE rows. |
+| `updateBatch($set, $referenceColumn)` | CASE/WHEN-keyed batch UPDATE. |
+| `delete($conditions, $purge)` | DELETE (or soft-delete) rows. |
+
+The two batch variants iterate the outer array and run the same builder once.
+
+---
+
+## `create`
+
+```php
+$posts = new \App\Model\Posts();
+
+$posts->create([
+ 'title' => 'My First Post',
+ 'body' => 'Hello, world.',
+]);
+
+$newId = $posts->getDatabase()->insertId();
+```
+
+When the model declares `$createdField`, that column is auto-filled with `date($timestampFormat)` just before execution.
+
+---
+
+## `createBatch`
+
+```php
+$posts->createBatch([
+ ['title' => 'A', 'body' => 'first body'],
+ ['title' => 'B', 'body' => 'second body'],
+ ['title' => 'C'], // body compiles to NULL
+]);
+```
+
+`$createdField` is applied to every row.
+
+---
+
+## `read`
+
+```php
+foreach ($posts->read()->rows() as $entity) {
+ echo $entity->title, PHP_EOL;
+}
+```
+
+Optional projection and inline conditions:
+
+```php
+$rows = $posts
+ ->read(
+ ['id', 'title'], // SELECT id, title
+ ['status' => 'published'] // WHERE status = :status
+ )
+ ->rows();
+```
+
+The result is a `DataMapperInterface` configured to hydrate `$entity` instances. Call `->asAssoc()` or `->asObject()` if you want a different fetch mode.
+
+Compose with the builder for anything more complex:
+
+```php
+$rows = $posts
+ ->select('id', 'title')
+ ->where('status', '=', 'published')
+ ->orderBy('id', 'DESC')
+ ->limit(10)
+ ->read()
+ ->rows();
+```
+
+See [08 — Extending the builder](08-extending-the-builder.md) for the full forwarded surface.
+
+---
+
+## `update`
+
+```php
+// By primary key — the PK is lifted out of $set into a WHERE clause:
+$posts->update(['id' => 5, 'title' => 'Renamed']);
+
+// With explicit conditions:
+$posts->update(['title' => 'Renamed'], ['id' => 5]);
+
+// Without conditions — affects ALL rows that match any pending WHERE chain:
+$posts->where('author_id', '=', 7)->update(['archived' => 1]);
+```
+
+When `$updatedField` is set, it is auto-filled with the current timestamp.
+
+When `$useSoftDeletes` is on, an additional `deletedField IS NULL` is added so soft-deleted rows are never touched.
+
+---
+
+## `updateBatch`
+
+```php
+$posts->updateBatch(
+ [
+ ['id' => 1, 'title' => 'Edited #1'],
+ ['id' => 2, 'title' => 'Edited #2'],
+ ],
+ referenceColumn: 'id', // defaults to $schemaId
+);
+```
+
+Generates a single CASE/WHEN UPDATE keyed by `$referenceColumn`.
+
+---
+
+## `delete`
+
+```php
+// Conditional delete:
+$posts->delete(['id' => 5]);
+
+// All rows that match a pre-existing WHERE chain:
+$posts->where('status', '=', 'spam')->delete();
+```
+
+With `$useSoftDeletes` on, `delete()` sets `$deletedField` instead. To bypass soft-delete and actually remove the row:
+
+```php
+$posts->delete(['id' => 5], purge: true);
+```
+
+---
+
+## `save(Entity)`
+
+`save()` picks between insert and update based on whether the entity carries a non-empty primary-key value:
+
+```php
+$entity = new \App\Entity\PostEntity(['title' => 'New']);
+$posts->save($entity); // create()
+
+$entity = new \App\Entity\PostEntity(['id' => 1, 'title' => 'Edit']);
+$posts->save($entity); // update()
+```
+
+---
+
+## How many rows changed?
+
+The Database tracks the last CRUD call's affected row count:
+
+```php
+$posts->update(['status' => 'archived'], ['author_id' => 7]);
+$posts->getDatabase()->affectedRows(); // e.g. 12
+```
+
+For SELECT this is driver-dependent: reliable on buffered drivers (MySQL); unreliable elsewhere. For INSERT/UPDATE/DELETE on the common drivers it returns the genuine affected-row count.
diff --git a/docs/05-soft-deletes.md b/docs/05-soft-deletes.md
new file mode 100644
index 0000000..3a4ce14
--- /dev/null
+++ b/docs/05-soft-deletes.md
@@ -0,0 +1,110 @@
+# 05 — Soft deletes
+
+When enabled, soft deletes turn `delete()` into a "mark this row as deleted" operation and automatically filter soft-deleted rows out of subsequent reads / updates.
+
+---
+
+## Setup
+
+```php
+class Posts extends \InitORM\ORM\Model
+{
+ protected string $schema = 'posts';
+
+ protected bool $useSoftDeletes = true;
+ protected ?string $deletedField = 'deleted_at'; // required
+}
+```
+
+The constructor enforces the invariant — `$useSoftDeletes = true` without a `$deletedField` raises `ModelException` before the model is usable.
+
+The `deletedField` column must be **nullable** in the underlying schema (it stores either a timestamp or `NULL`).
+
+---
+
+## Soft-deleting a row
+
+```php
+$posts->delete(['id' => 5]);
+```
+
+This compiles to (roughly):
+
+```sql
+UPDATE posts SET deleted_at = :deleted_at WHERE deleted_at IS NULL AND id = :id
+```
+
+— note the auto-injected `deleted_at IS NULL` predicate, which prevents an already-deleted row from being "deleted" again.
+
+To remove the row for real:
+
+```php
+$posts->delete(['id' => 5], purge: true);
+```
+
+The `purge: true` flag bypasses soft-delete and issues a real `DELETE` statement.
+
+---
+
+## Reading
+
+`read()` automatically excludes soft-deleted rows:
+
+```php
+foreach ($posts->read()->rows() as $entity) {
+ // Only rows where deleted_at IS NULL.
+}
+```
+
+To read **only** soft-deleted rows on the next read, use `onlyDeleted()`:
+
+```php
+foreach ($posts->onlyDeleted()->read()->rows() as $tombstone) {
+ // Only rows where deleted_at IS NOT NULL.
+}
+```
+
+The `onlyDeleted` flag is consumed by the next `read()` — subsequent reads revert to the default scope:
+
+```php
+$posts->onlyDeleted()->read()->rows(); // soft-deleted only
+$posts->read()->rows(); // default scope
+```
+
+---
+
+## Updates and `ignoreDeleted()`
+
+`update()` and `updateBatch()` automatically add `deleted_at IS NULL` to their WHERE chain so they never touch soft-deleted rows. If you build a custom chain (e.g. via direct `where()` calls) and want the same protection without calling `update()`, use `ignoreDeleted()`:
+
+```php
+$posts
+ ->ignoreDeleted()
+ ->where('author_id', '=', 7)
+ ->update(['archived' => 1]);
+```
+
+`ignoreDeleted()` adds `deletedField IS NULL` to the pending WHERE and returns the model (chainable). `onlyDeleted()`, by contrast, only flips an internal flag — the WHERE is added by the next `read()`.
+
+---
+
+## Restoring a soft-deleted row
+
+There is no built-in `restore()` helper — soft-delete is a one-bit, one-column convention, so restoration is a plain update with `purge`-style intent:
+
+```php
+// Bypass the auto-injected "deleted_at IS NULL" by setting deleted_at explicitly.
+$posts->getDatabase()->update('posts', ['deleted_at' => null], ['id' => 5]);
+```
+
+Or, if you prefer to stay on the model API, expose a thin helper on your subclass:
+
+```php
+class Posts extends \InitORM\ORM\Model
+{
+ public function restore(int $id): bool
+ {
+ return $this->db->update($this->getSchema(), ['deleted_at' => null], ['id' => $id]);
+ }
+}
+```
diff --git a/docs/06-timestamps.md b/docs/06-timestamps.md
new file mode 100644
index 0000000..8409c6e
--- /dev/null
+++ b/docs/06-timestamps.md
@@ -0,0 +1,102 @@
+# 06 — Timestamps
+
+Each model can auto-fill up to three timestamp columns: a creation timestamp on insert, an update timestamp on every update, and a deletion timestamp on soft-delete.
+
+---
+
+## Properties
+
+| Property | Type | Default | Effect |
+| ------------------- | -------------- | ------------------ | --------------------------------------------------------------- |
+| `$createdField` | `string\|null` | `null` | Filled on every `create()` / `createBatch()`. |
+| `$updatedField` | `string\|null` | `null` | Filled on every `update()` / `updateBatch()`. |
+| `$deletedField` | `string\|null` | `null` | Filled on `delete()` when `$useSoftDeletes` is true. |
+| `$timestampFormat` | `string` | `'Y-m-d H:i:s'` | `date()` format string used for all three. |
+
+Each column is independent — enable any subset that fits your schema. Leaving a property as `null` disables the corresponding auto-fill entirely.
+
+---
+
+## Examples
+
+### Created + updated
+
+```php
+class Posts extends \InitORM\ORM\Model
+{
+ protected string $schema = 'posts';
+
+ protected ?string $createdField = 'created_at';
+ protected ?string $updatedField = 'updated_at';
+}
+
+$posts = new Posts();
+$posts->create(['title' => 'Hello']);
+// INSERT INTO posts (title, created_at) VALUES (:title, :created_at)
+
+$posts->update(['id' => 1, 'title' => 'Edited']);
+// UPDATE posts SET title = :title, updated_at = :updated_at WHERE id = :id
+```
+
+### Custom format
+
+```php
+class Posts extends \InitORM\ORM\Model
+{
+ protected ?string $createdField = 'created_at';
+ protected string $timestampFormat = 'Y-m-d\TH:i:sP'; // ISO-8601 with timezone
+}
+```
+
+`date()` formats are documented at . The format is shared across all three columns.
+
+### Soft delete
+
+```php
+class Posts extends \InitORM\ORM\Model
+{
+ protected string $schema = 'posts';
+
+ protected bool $useSoftDeletes = true;
+ protected ?string $deletedField = 'deleted_at';
+}
+
+$posts->delete(['id' => 5]);
+// UPDATE posts SET deleted_at = :deleted_at WHERE deleted_at IS NULL AND id = :id
+```
+
+See [05 — Soft deletes](05-soft-deletes.md).
+
+---
+
+## Overriding the value
+
+Auto-fill only kicks in when the column is absent from `$set`. To pass an explicit value, include it yourself:
+
+```php
+$posts->create([
+ 'title' => 'Backfilled',
+ 'created_at' => '2020-01-01 00:00:00',
+]);
+```
+
+The model writes auto-filled timestamps **after** copying the caller-supplied `$set`, so a value the caller wrote into `$set[$createdField]` will be overwritten. To preserve a custom value, either disable auto-fill for that model or use the underlying `Database` API directly:
+
+```php
+$posts->getDatabase()->create($posts->getSchema(), [
+ 'title' => 'Backfilled',
+ 'created_at' => '2020-01-01 00:00:00',
+]);
+```
+
+---
+
+## Timezone
+
+`date()` uses the runtime's default timezone (`date_default_timezone_get()` / `date.timezone` INI). For consistent results, set this once at boot:
+
+```php
+date_default_timezone_set('UTC');
+```
+
+If you store timestamps in a typed column (`TIMESTAMP` on MySQL, `timestamp with time zone` on PostgreSQL), the database driver may apply its own conversion on read — verify the round trip in tests.
diff --git a/docs/07-permission-gates.md b/docs/07-permission-gates.md
new file mode 100644
index 0000000..426e95a
--- /dev/null
+++ b/docs/07-permission-gates.md
@@ -0,0 +1,90 @@
+# 07 — Permission gates
+
+Each model carries four boolean flags, one per operation. Flip any to `false` and the matching typed exception fires at the start of the call — before any SQL is built or sent.
+
+| Flag | Operations guarded | Exception |
+| -------------- | ---------------------------------------- | ---------------------- |
+| `$writable` | `create()`, `createBatch()` | `WritableException` |
+| `$readable` | `read()` | `ReadableException` |
+| `$updatable` | `update()`, `updateBatch()` | `UpdatableException` |
+| `$deletable` | `delete()` | `DeletableException` |
+
+All four exceptions extend [`ModelException`](../src/Exceptions/ModelException.php), so a single catch handles them collectively when you do not care which gate tripped.
+
+---
+
+## When to use them
+
+- **Read-only models**: a configuration or lookup table that the application reads but never mutates.
+
+ ```php
+ class Configuration extends \InitORM\ORM\Model
+ {
+ protected string $schema = 'configuration';
+ protected bool $writable = false;
+ protected bool $updatable = false;
+ protected bool $deletable = false;
+ }
+
+ (new Configuration())->create(['k' => 'v']); // WritableException
+ ```
+
+- **Write-only audit logs**: a table the application appends to but never reads through the model.
+
+ ```php
+ class AuditLog extends \InitORM\ORM\Model
+ {
+ protected string $schema = 'audit_log';
+ protected bool $readable = false;
+ protected bool $updatable = false;
+ protected bool $deletable = false;
+ }
+ ```
+
+- **Append-only with retention**: a journal that the application writes and (rarely) deletes via a maintenance script.
+
+ ```php
+ class Events extends \InitORM\ORM\Model
+ {
+ protected string $schema = 'events';
+ protected bool $updatable = false;
+ }
+ ```
+
+---
+
+## Error messages
+
+The exception messages include the fully-qualified class name of the offending model:
+
+```
+App\Model\Configuration is not writable.
+App\Model\AuditLog is not readable.
+```
+
+— which makes the failure easy to locate even when the stack trace skirts the call site (e.g. when the call originates inside a closure passed to a transaction).
+
+---
+
+## Catching collectively vs. specifically
+
+```php
+use InitORM\ORM\Exceptions\ModelException;
+use InitORM\ORM\Exceptions\WritableException;
+
+try {
+ $config->create([...]);
+} catch (WritableException $e) {
+ // Specific: read-only configuration table can't be written.
+} catch (ModelException $e) {
+ // Generic ORM-layer failure (entity issues, model misconfiguration, etc.).
+}
+```
+
+`ModelException` is also raised by the constructor when `$useSoftDeletes` is enabled without a `$deletedField`, so it is the right umbrella for "the ORM said no".
+
+---
+
+## Gates do not replace database constraints
+
+The gates live in PHP — they prevent the model's CRUD methods from issuing the operation, but they do not stop a determined caller from sidestepping the model and calling `$model->getDatabase()` directly. For schema-level invariants (read-only roles, foreign-key cascades, triggers), enforce them in the database itself; gates are a code-hygiene aid, not an authorization layer.
diff --git a/docs/08-extending-the-builder.md b/docs/08-extending-the-builder.md
new file mode 100644
index 0000000..609c4ef
--- /dev/null
+++ b/docs/08-extending-the-builder.md
@@ -0,0 +1,103 @@
+# 08 — Extending the builder
+
+A `Model` is a thin wrapper around a `DatabaseInterface`. Every call you make to the model first checks for a method on the model itself; anything else flows downward through `__call`:
+
+```
+Model::someBuilderMethod()
+ → Database::__call()
+ → QueryBuilder::someBuilderMethod() (returns the builder)
+ → Database returns itself (because the builder returned itself)
+Model returns itself (because the Database returned itself)
+```
+
+The net effect: any method on the [`DatabaseInterface`](https://github.com/InitORM/Database/blob/master/src/Interfaces/DatabaseInterface.php) and any method on [`QueryBuilderInterface`](https://github.com/InitORM/QueryBuilder/blob/master/src/QueryBuilderInterface.php) is callable directly on the model, and fluent chains stay rooted in the model.
+
+---
+
+## A full chain
+
+```php
+$posts = new \App\Model\Posts();
+
+$rows = $posts
+ ->select('id', 'title')
+ ->where('status', '=', 'published')
+ ->andWhere('author_id', '=', 7)
+ ->orderBy('id', 'DESC')
+ ->limit(10)
+ ->offset(0)
+ ->read() // hydrates as PostEntity instances
+ ->rows();
+```
+
+`select`, `where`, `andWhere`, `orderBy`, `limit`, `offset` are all builder calls — forwarded through Database, re-wrapped to the Model. `read()` is the Model's own method.
+
+---
+
+## The forwarded surface
+
+The builder ships ~100 fluent methods. The most useful families:
+
+### Projection
+
+- `select(...$columns)`, `clearSelect()`
+- `selectAs($col, $alias)`, `selectDistinct(...)`, `selectMax(...)`, `selectMin(...)`, `selectAvg(...)`, `selectSum(...)`, `selectCount(...)`, `selectCountDistinct(...)`
+- `selectUpper(...)`, `selectLower(...)`, `selectLength(...)`, `selectMid(...)`, `selectLeft(...)`, `selectRight(...)`, `selectCoalesce(...)`, `selectConcat(...)`
+
+### Joins
+
+- `join($table, $on, $type)`
+- `innerJoin`, `leftJoin`, `rightJoin`, `leftOuterJoin`, `rightOuterJoin`, `selfJoin`, `naturalJoin`
+
+### WHERE family
+
+- `where($col, $op, $val)`, `andWhere`, `orWhere`
+- `whereIn`, `whereNotIn`, `orWhereIn`, `andWhereIn`, …
+- `whereIsNull`, `whereIsNotNull`, `andWhereIsNull`, `orWhereIsNull`
+- `between`, `andBetween`, `orBetween`, `notBetween`, …
+- `like`, `andLike`, `orLike`, `notLike`, `startLike`, `endLike`, …
+- `regexp`, `soundex`, `findInSet`, `notFindInSet`
+
+### Grouping, sorting, paging
+
+- `groupBy(...)`, `having(...)`
+- `orderBy($col, $direction)`
+- `limit($n)`, `offset($n)`
+
+### Other
+
+- `subQuery(Closure $closure, ?string $alias = null, bool $isInterval = true)`
+- `group(Closure $closure, string $logical = 'AND')` — grouped WHERE
+- `raw($sql)` / `DB::raw($sql)` — escape-hatch for hand-written SQL fragments
+
+For the exhaustive list, see the `@method static` annotations on [`InitORM\Database\Facade\DB`](https://github.com/InitORM/Database/blob/master/src/Facade/DB.php) — every method listed there is callable on a model with the same signature.
+
+---
+
+## Mixing builder calls and CRUD
+
+Builder methods accumulate state on the underlying query builder. CRUD calls (`read`, `update`, `delete`, etc.) consume that state and then reset it — so the next CRUD call starts clean:
+
+```php
+$posts->where('id', '=', 5)->update(['title' => 'X']); // uses WHERE
+$posts->update(['title' => 'Y'], ['id' => 6]); // clean slate; explicit conditions
+```
+
+If you need two independent queries against the same connection, spin off a fresh builder via the Database:
+
+```php
+$reports = $posts->getDatabase()->withFreshBuilder();
+$reports->read('events')->rows();
+```
+
+---
+
+## When forwarding fails
+
+`Model::__call` raises `BadMethodCallException` when the method does not exist on the Database, on the builder, or on any of their `@mixin` surfaces:
+
+```php
+$posts->thisIsNotARealMethod(); // BadMethodCallException
+```
+
+The exception message includes the model class and the requested method name. The original `DatabaseException` raised by the Database layer is chained as the previous exception (`getPrevious()`).
diff --git a/docs/09-multiple-connections.md b/docs/09-multiple-connections.md
new file mode 100644
index 0000000..e59c4d5
--- /dev/null
+++ b/docs/09-multiple-connections.md
@@ -0,0 +1,105 @@
+# 09 — Multiple connections
+
+Models bind to the shared `DB` facade by default. For models that need a separate connection — a reporting database, a sharded tenant store, a read replica — set `$credentials` on the subclass.
+
+---
+
+## The default: shared `DB` facade
+
+```php
+use InitORM\Database\Facade\DB;
+
+DB::createImmutable([
+ 'dsn' => 'mysql:host=primary;dbname=app;charset=utf8mb4',
+ 'username' => 'app',
+ 'password' => 'secret',
+]);
+
+class Posts extends \InitORM\ORM\Model
+{
+ protected string $schema = 'posts';
+}
+
+(new Posts())->getDatabase() === DB::getDatabase(); // true
+```
+
+Every model with `$credentials = null` (the default) shares the same Database instance — which means they all share the same underlying PDO connection and query builder pool.
+
+---
+
+## A standalone connection per model
+
+Set `$credentials` to give a model its own connection:
+
+```php
+class ReportsEvents extends \InitORM\ORM\Model
+{
+ protected string $schema = 'events';
+
+ protected ?array $credentials = [
+ 'driver' => 'pgsql',
+ 'host' => 'reports.internal',
+ 'database' => 'reports',
+ 'username' => 'reports_ro',
+ 'password' => '…',
+ ];
+}
+```
+
+Internally, the constructor calls `DB::connect($credentials)`, which builds a fresh `Database` (and underlying `Connection`) without touching the shared facade slot.
+
+The `$credentials` array is passed verbatim to the DBAL `Connection` constructor — see the [Database configuration reference](https://github.com/InitORM/Database/blob/master/README.md#configuration-reference) for every supported key (`dsn`, `host`, `port`, `database`, `username`, `password`, `charset`, `collation`, `driver`, `options`, `queryOptions`, `log`, `debug`, `queryLogs`).
+
+---
+
+## Multiple standalone connections
+
+Each subclass with its own `$credentials` gets its own Database instance. Two models with identical `$credentials` arrays still produce two separate connections — there is no de-duplication at the model layer.
+
+```php
+class TenantA extends \InitORM\ORM\Model
+{
+ protected ?array $credentials = ['driver' => 'mysql', 'host' => 'tenant-a', ...];
+}
+
+class TenantB extends \InitORM\ORM\Model
+{
+ protected ?array $credentials = ['driver' => 'mysql', 'host' => 'tenant-b', ...];
+}
+```
+
+If you need shared, named connections, build the `Database` objects yourself and cache them in a container, then expose them as `$credentials` per subclass via a factory:
+
+```php
+$connections = [
+ 'reports' => new \InitORM\Database\Database([...]),
+ 'audit' => new \InitORM\Database\Database([...]),
+];
+
+class Reports extends \InitORM\ORM\Model
+{
+ public function __construct(\InitORM\Database\Interfaces\DatabaseInterface $db)
+ {
+ $this->schema = 'events';
+ // Skip the constructor's facade lookup by reassigning $db ourselves
+ // after parent::__construct() — but we need a non-null $credentials
+ // sentinel to avoid the facade path. Simplest is to bypass entirely:
+ parent::__construct();
+ $this->db = $db; // requires making $db protected, or using a property
+ }
+}
+```
+
+This is an unusual setup; the conventional path is to either share the facade (one app, one database) or to give each model its own `$credentials` array.
+
+---
+
+## Swapping the facade target
+
+`DB::createImmutable()` deliberately throws if called twice — silent reconfiguration of the application-wide connection is a footgun. To explicitly swap:
+
+```php
+DB::replaceImmutable($newDatabase); // pass null to clear the slot
+```
+
+In tests, you usually want to clear and rebuild per test case. The package's own test base does exactly that — see [`tests/Support/AbstractModelTestCase.php`](../tests/Support/AbstractModelTestCase.php) and [10 — Testing models](10-testing-models.md).
diff --git a/docs/10-testing-models.md b/docs/10-testing-models.md
new file mode 100644
index 0000000..2e4d50b
--- /dev/null
+++ b/docs/10-testing-models.md
@@ -0,0 +1,124 @@
+# 10 — Testing models
+
+Models are testable without a database server. The package's own suite hits an **in-memory SQLite** connection, which is fast and dependency-free — every test gets a fresh schema and seed data, isolated by construction.
+
+---
+
+## Requirements
+
+- `ext-pdo`
+- `ext-pdo_sqlite`
+- PHPUnit 10+
+
+The package's `composer.json` lists `phpunit/phpunit ^10.5` and `ext-pdo_sqlite: *` under `require-dev`, so a fresh `composer install` gives you everything you need.
+
+---
+
+## The test base
+
+[`tests/Support/AbstractModelTestCase.php`](../tests/Support/AbstractModelTestCase.php) sets up a fresh facade-wired Database per test:
+
+```php
+abstract class AbstractModelTestCase extends \PHPUnit\Framework\TestCase
+{
+ protected ConnectionInterface $connection;
+ protected DatabaseInterface $db;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedPosts($this->connection);
+
+ $this->db = new Database($this->connection);
+ DB::replaceImmutable($this->db);
+ }
+
+ protected function tearDown(): void
+ {
+ DB::replaceImmutable(null);
+ parent::tearDown();
+ }
+}
+```
+
+Two important details:
+
+1. Each `setUp()` produces a **new** in-memory SQLite database — `:memory:` is per-PDO-handle, so reusing a Database across tests would imply reusing schema and rows.
+2. `DB::replaceImmutable(null)` in `tearDown()` clears the facade slot so the next test does not inherit it.
+
+---
+
+## Fixtures
+
+Test fixtures live under [`tests/Support/Fixtures/`](../tests/Support/Fixtures/). The package ships:
+
+- `PostModel` — a soft-delete-enabled model with auto-filled timestamps.
+- `PostEntity` — an entity with both an accessor and a mutator.
+- `TagModel` — a model with no explicit `$schema` (exercises auto-derivation).
+- `NonWritablePostModel`, `NonReadablePostModel`, `NonUpdatablePostModel`, `NonDeletablePostModel` — gate-checked variants.
+
+---
+
+## A complete example
+
+```php
+final class MyPostsTest extends \InitORM\ORM\Tests\Support\AbstractModelTestCase
+{
+ public function test_creates_a_post(): void
+ {
+ $posts = new \InitORM\ORM\Tests\Support\Fixtures\PostModel();
+ $posts->create(['title' => 'Hello', 'body' => 'world']);
+
+ $rows = $this->db->table('posts')->read()->asAssoc()->rows();
+ self::assertCount(4, $rows); // 3 seeded + 1 new
+ }
+
+ public function test_soft_delete_hides_row(): void
+ {
+ $posts = new \InitORM\ORM\Tests\Support\Fixtures\PostModel();
+ $posts->delete(['id' => 1]);
+
+ $remaining = $posts->read()->rows();
+ self::assertCount(1, $remaining); // 2 seeded - 1 just-deleted
+ }
+}
+```
+
+---
+
+## Asserting against bound parameters
+
+Set `enableQueryLog()` on the Database before the operation and inspect the buffer afterwards:
+
+```php
+$this->db->enableQueryLog();
+$posts->create(['title' => 'X']);
+
+$entries = $this->db->getQueryLogs();
+self::assertStringContainsString('INSERT INTO', $entries[0]['query']);
+self::assertSame('X', $entries[0]['args'][':title']);
+```
+
+Useful when locking in *exactly* what SQL the model generates.
+
+---
+
+## Running the suite
+
+```bash
+composer test # phpunit
+composer test:coverage # phpunit with HTML coverage at build/coverage/
+composer qa # phpcs + phpstan + phpunit
+```
+
+CI runs the same matrix across PHP 8.1, 8.2, 8.3, and 8.4 on every push and pull request — see the [`.github/workflows/`](../.github/workflows/) directory.
+
+---
+
+## Testing models against a real database
+
+The same patterns work against MySQL or PostgreSQL — just point `SqliteHelper::makeConnection()` (or your own builder) at the right `dsn` and credentials. In CI, this typically means a service container (GitHub Actions' `services:` block, Docker, etc.) per workflow.
+
+Generally, prefer SQLite in-memory for fast, per-test isolation, and run a smaller integration suite against the real driver to catch dialect differences (identifier quoting, type coercion, transaction visibility).
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
new file mode 100644
index 0000000..3a89a6d
--- /dev/null
+++ b/phpcs.xml.dist
@@ -0,0 +1,31 @@
+
+
+ PSR-12 with a 120-char soft cap on src/, relaxed for tests.
+
+ src
+ tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tests/*
+
+
+
+
+ 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..2cc84c8
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,33 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+ src/Interfaces
+ src/Exceptions
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Entity.php b/src/Entity.php
index d03d860..8721886 100644
--- a/src/Entity.php
+++ b/src/Entity.php
@@ -1,140 +1,213 @@
-
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\ORM;
-
-use InitORM\ORM\Exceptions\EntityException;
-use InitORM\ORM\Interfaces\EntityInterface;
-use InitORM\ORM\Utils\Helper;
-
-class Entity implements EntityInterface
-{
-
- protected array $__attributes = [];
-
- protected array $__attributesOriginal = [];
-
- public function __construct(?array $data = [])
- {
- $this->setUp($data);
- }
-
- /**
- * @param $name
- * @param $arguments
- * @return mixed
- * @throws EntityException
- */
- public function __call($name, $arguments)
- {
- if (str_ends_with($name, 'Attribute') === false) {
- throw new EntityException($name);
- }
-
- $attr = Helper::camelCaseToSnakeCase(substr($name, 3, -9));
-
- return match (substr($name, 0, 3)) {
- 'get' => $this->__attributes[$attr] ?? null,
- 'set' => $this->__attributes[$attr] = $arguments[0],
- default => throw new EntityException($name)
- };
- }
-
- public function __set($name, $value)
- {
- $methodName = 'set' . Helper::snakeCaseToPascalCase($name) . 'Attribute';
- if(method_exists($this, $methodName)){
- $this->{$methodName}($value);
- return $value;
- }
- return $this->__attributes[$name] = $value;
- }
-
- public function __get($name)
- {
- $methodName = 'get' . Helper::snakeCaseToPascalCase($name) . 'Attribute';
- if(method_exists($this, $methodName)){
- return $this->{$methodName}();
- }
- return $this->__attributes[$name] ?? null;
- }
-
- public function __isset($name)
- {
- return isset($this->__attributes[$name]);
- }
-
- public function __unset($name)
- {
- if(isset($this->__attributes[$name])){
- unset($this->__attributes[$name]);
- }
- }
-
- public function __debugInfo()
- {
- return $this->__attributes;
- }
-
- /**
- * @inheritDoc
- */
- public function toArray(): array
- {
- return $this->__attributes;
- }
-
- /**
- * @inheritDoc
- */
- public function getAttributes(): array
- {
- return $this->toArray();
- }
-
- /**
- * @param array|null $data
- * @return $this
- */
- protected function setUp(?array $data = null): self
- {
- $this->syncOriginal()
- ->fill($data);
- return $this;
- }
-
- /**
- * @param array|null $data
- * @return $this
- */
- protected function fill(?array $data = null): self
- {
- if($data !== null){
- foreach ($data as $key => $value) {
- $this->__set($key, $value);
- }
- }
- return $this;
- }
-
- /**
- * @return $this
- */
- protected function syncOriginal(): self
- {
- $this->__attributesOriginal = $this->__attributes;
- return $this;
- }
-
-}
+name}" property syntax dispatches through
+ * {@see self::__get()} / {@see self::__set()}:
+ *
+ * - When a subclass declares `getColumnAttribute($value)` or
+ * `setColumnAttribute($value)`, that method is invoked with the current
+ * stored value (accessor) or the incoming value (mutator).
+ * - Otherwise the value is read from / written to the internal attribute
+ * bag directly.
+ *
+ * Mutator bodies MUST write the transformed value back through
+ * {@see self::setAttribute()} — NOT through `$this->column = ...`. Inside
+ * a class method, undeclared property assignment bypasses {@see __set()}
+ * and creates a dynamic property instead (deprecated in PHP 8.2+, fatal in
+ * a future PHP version), so the value never reaches the attribute bag and
+ * later reads see a stale value.
+ */
+class Entity implements EntityInterface
+{
+ /**
+ * Column → value bag for this row.
+ *
+ * @var array
+ */
+ protected array $attributes = [];
+
+ /**
+ * Snapshot of {@see self::$attributes} taken at construction time (and
+ * any subsequent {@see self::syncOriginal()} call). Subclasses can use
+ * this to implement dirty-tracking.
+ *
+ * @var array
+ */
+ protected array $attributesOriginal = [];
+
+ /**
+ * @param array|null $data Initial column values, applied
+ * through mutators when present. Pass null for an empty entity.
+ */
+ public function __construct(?array $data = [])
+ {
+ $this->fill($data)
+ ->syncOriginal();
+ }
+
+ /**
+ * Default get/set fallback for the `{verb}{Column}Attribute` family —
+ * invoked only when a subclass does not define a real method by that
+ * name. Direct {@code $entity->col} property access does not route
+ * through this method.
+ *
+ * @param array $arguments
+ *
+ * @throws EntityException When the method name does not match the
+ * `getXAttribute` / `setXAttribute` pattern.
+ */
+ public function __call(string $name, array $arguments): mixed
+ {
+ if (!str_ends_with($name, 'Attribute')) {
+ throw new EntityException(sprintf('Unknown entity method "%s".', $name));
+ }
+
+ $prefix = substr($name, 0, 3);
+ $column = Helper::camelCaseToSnakeCase(substr($name, 3, -9));
+
+ return match ($prefix) {
+ 'get' => $this->attributes[$column] ?? null,
+ 'set' => $this->attributes[$column] = $arguments[0] ?? null,
+ default => throw new EntityException(sprintf('Unknown entity method "%s".', $name)),
+ };
+ }
+
+ /**
+ * Property write. When a `set{Column}Attribute($value)` method exists on
+ * the subclass, it is invoked with $value; the method body is expected
+ * to write the transformed value back via {@see self::setAttribute()}.
+ */
+ public function __set(string $name, mixed $value): void
+ {
+ $method = 'set' . Helper::snakeCaseToPascalCase($name) . 'Attribute';
+
+ if (method_exists($this, $method)) {
+ $this->{$method}($value);
+ return;
+ }
+
+ $this->attributes[$name] = $value;
+ }
+
+ /**
+ * Property read. When a `get{Column}Attribute($value)` method exists on
+ * the subclass, it is invoked with the stored value (or null) and its
+ * return value is propagated to the caller.
+ */
+ public function __get(string $name): mixed
+ {
+ $method = 'get' . Helper::snakeCaseToPascalCase($name) . 'Attribute';
+
+ if (method_exists($this, $method)) {
+ return $this->{$method}($this->attributes[$name] ?? null);
+ }
+
+ return $this->attributes[$name] ?? null;
+ }
+
+ public function __isset(string $name): bool
+ {
+ return isset($this->attributes[$name]);
+ }
+
+ public function __unset(string $name): void
+ {
+ unset($this->attributes[$name]);
+ }
+
+ /**
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function toArray(): array
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getAttributes(): array
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getOriginal(): array
+ {
+ return $this->attributesOriginal;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getAttribute(string $name): mixed
+ {
+ return $this->attributes[$name] ?? null;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function setAttribute(string $name, mixed $value): static
+ {
+ $this->attributes[$name] = $value;
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function syncOriginal(): static
+ {
+ $this->attributesOriginal = $this->attributes;
+
+ return $this;
+ }
+
+ /**
+ * Populate the entity from an associative array, dispatching through
+ * mutators where they exist. Null is treated as a no-op.
+ *
+ * @param array|null $data
+ */
+ protected function fill(?array $data = null): static
+ {
+ if ($data === null) {
+ return $this;
+ }
+
+ foreach ($data as $key => $value) {
+ $this->__set((string) $key, $value);
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Exceptions/DeletableException.php b/src/Exceptions/DeletableException.php
index 86bbbba..a5be5e3 100644
--- a/src/Exceptions/DeletableException.php
+++ b/src/Exceptions/DeletableException.php
@@ -1,19 +1,18 @@
-
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\ORM\Exceptions;
-
-class DeletableException extends ModelException
-{
-}
+
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\ORM\Exceptions;
-
-use Exception;
-
-class EntityException 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\ORM\Exceptions;
-
-use Exception;
-
-class ModelException 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\ORM\Exceptions;
-
-class ReadableException extends ModelException
-{
-}
+
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\ORM\Exceptions;
-
-class UpdatableException extends ModelException
-{
-}
+
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\ORM\Exceptions;
-
-class WritableException extends ModelException
-{
-}
+
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-namespace InitORM\ORM\Interfaces;
-
-interface EntityInterface
-{
-
- /**
- * @return array
- */
- public function toArray(): array;
-
- /**
- * @return array
- */
- public function getAttributes(): array;
-
-}
+
+ */
+ public function toArray(): array;
+
+ /**
+ * Live column → value map. Returned by reference to the entity's own
+ * storage — callers must not assume the array is a snapshot.
+ *
+ * @return array
+ */
+ public function getAttributes(): array;
+
+ /**
+ * Snapshot of attributes captured at construction time (or at the last
+ * {@see self::syncOriginal()} call). Used as the baseline for any
+ * dirty-tracking a consumer wants to layer on top.
+ *
+ * @return array
+ */
+ public function getOriginal(): array;
+
+ /**
+ * Read a single attribute without going through the accessor hook. Use
+ * this from within an accessor method to avoid re-entry.
+ */
+ public function getAttribute(string $name): mixed;
+
+ /**
+ * Write a single attribute without going through the mutator hook. Use
+ * this from within a mutator method to write the transformed value back
+ * into the attribute bag without triggering recursion.
+ */
+ public function setAttribute(string $name, mixed $value): static;
+
+ /**
+ * Capture the current attribute set as the new "original" baseline.
+ * Useful after a save() in subclasses that implement dirty-tracking.
+ */
+ public function syncOriginal(): static;
+}
diff --git a/src/Interfaces/ModelInterface.php b/src/Interfaces/ModelInterface.php
index bc84a99..862a403 100644
--- a/src/Interfaces/ModelInterface.php
+++ b/src/Interfaces/ModelInterface.php
@@ -1,116 +1,156 @@
-
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\ORM\Interfaces;
-
-use InitORM\Database\Interfaces\DatabaseInterface;
-use InitORM\DBAL\Connection\Exceptions\SQLExecuteException;
-use InitORM\QueryBuilder\Exceptions\QueryBuilderException;
-use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface;
-use \InitORM\ORM\Exceptions\{WritableException, ReadableException, UpdatableException, DeletableException};
-
-interface ModelInterface
-{
-
- public function __construct();
-
-
- /**
- * @return string
- */
- public function getSchema(): string;
-
- /**
- * @return string
- */
- public function getSchemaId(): string;
-
- /**
- * @param array $set
- * @return bool
- * @throws SQLExecuteException
- * @throws QueryBuilderException
- * @throws WritableException
- */
- public function create(array $set = []): bool;
-
- /**
- * @param array $set
- * @return bool
- * @throws SQLExecuteException
- * @throws QueryBuilderException
- * @throws WritableException
- */
- public function createBatch(array $set = []): bool;
-
- /**
- * @param array $selector
- * @param array $conditions
- * @return DataMapperInterface
- * @throws SQLExecuteException
- * @throws QueryBuilderException
- * @throws ReadableException
- */
- public function read(array $selector = [], array $conditions = []): DataMapperInterface;
-
- /**
- * @param array $set
- * @return bool
- * @throws SQLExecuteException
- * @throws QueryBuilderException
- * @throws UpdatableException
- */
- public function update(array $set = []): bool;
-
- /**
- * @param array $set
- * @param string|null $referenceColumn
- * @return bool
- * @throws SQLExecuteException
- * @throws QueryBuilderException
- * @throws UpdatableException
- */
- public function updateBatch(array $set = [], ?string $referenceColumn = null): bool;
-
- /**
- * @param array|null $conditions
- * @param bool $purge
- * @return bool
- * @throws SQLExecuteException
- * @throws QueryBuilderException
- * @throws DeletableException
- */
- public function delete(?array $conditions = null, bool $purge = false): bool;
-
- /**
- * @param EntityInterface $entity
- * @return bool
- * @throws SQLExecuteException
- * @throws QueryBuilderException
- * @throws WritableException
- * @throws UpdatableException
- */
- public function save(EntityInterface $entity): bool;
-
- /**
- * @return self
- */
- public function onlyDeleted(): self;
-
- /**
- * @return self
- */
- public function ignoreDeleted(): self;
-
-}
+getDatabase()->transaction(fn () => …);
+ */
+ public function getDatabase(): DatabaseInterface;
+
+ /**
+ * Insert a single row. When configured, {@see \InitORM\ORM\Model::$createdField}
+ * is auto-filled with {@code date($timestampFormat)} just before execution.
+ *
+ * @param array $set Column → value map.
+ *
+ * @throws WritableException When {@code $writable} is false.
+ */
+ public function create(array $set = []): bool;
+
+ /**
+ * Insert multiple rows in a single statement.
+ *
+ * @param array> $set One row per element.
+ *
+ * @throws WritableException When {@code $writable} is false.
+ */
+ public function createBatch(array $set = []): bool;
+
+ /**
+ * Compile and execute a SELECT, returning the result wrapped in a
+ * {@see DataMapperInterface} configured to hydrate the model's
+ * {@code $entity} class.
+ *
+ * When soft-deletes are enabled, the WHERE clause is augmented with a
+ * `deletedField IS NULL` predicate (or `IS NOT NULL` when
+ * {@see self::onlyDeleted()} was called immediately before).
+ *
+ * @param array $selector
+ * Optional projection columns.
+ * @param array $conditions
+ * Optional WHERE shortcuts (string-keyed → `where(key, '=', value)`;
+ * integer-keyed → `where(value)`).
+ *
+ * @throws ReadableException When {@code $readable} is false.
+ */
+ public function read(array $selector = [], array $conditions = []): DataMapperInterface;
+
+ /**
+ * Update rows.
+ *
+ * When the model has a non-empty {@code $schemaId} and {@code $set}
+ * contains a value for it, that PK is automatically lifted out into a
+ * WHERE clause (preventing the PK from being written) and the
+ * {@code $set} entry is removed.
+ *
+ * {@see \InitORM\ORM\Model::$updatedField} is auto-filled when set.
+ *
+ * @param array $set
+ * @param array|null $conditions Optional WHERE
+ * shortcuts (same semantics as {@see self::read()}).
+ *
+ * @throws UpdatableException When {@code $updatable} is false.
+ */
+ public function update(array $set = [], ?array $conditions = null): bool;
+
+ /**
+ * Update rows in a single CASE/WHEN-keyed batch.
+ *
+ * @param array> $set
+ * @param string|null $referenceColumn The column
+ * used as the CASE key. Defaults to {@code $schemaId}.
+ *
+ * @throws UpdatableException When {@code $updatable} is false.
+ */
+ public function updateBatch(array $set = [], ?string $referenceColumn = null): bool;
+
+ /**
+ * Delete rows. With soft-deletes on, the row is marked via
+ * {@see \InitORM\ORM\Model::$deletedField} unless {@code $purge} is true.
+ *
+ * @param array|null $conditions Optional WHERE
+ * shortcuts (same semantics as {@see self::read()}).
+ * @param bool $purge When true, soft-delete
+ * is bypassed and a real DELETE is issued.
+ *
+ * @throws DeletableException When {@code $deletable} is false.
+ */
+ public function delete(?array $conditions = null, bool $purge = false): bool;
+
+ /**
+ * Convenience: insert the entity when no PK is set, otherwise update it.
+ *
+ * @throws WritableException
+ * @throws UpdatableException
+ */
+ public function save(EntityInterface $entity): bool;
+
+ /**
+ * Mark the *next* {@see self::read()} call as targeting soft-deleted
+ * rows (`deletedField IS NOT NULL` instead of `IS NULL`). The flag is
+ * consumed by that next read and resets afterwards.
+ */
+ public function onlyDeleted(): static;
+
+ /**
+ * Immediately add `deletedField IS NULL` to the current pending WHERE.
+ * Used internally by update/delete; safe to call from user code to
+ * exclude soft-deleted rows from a custom builder chain.
+ */
+ public function ignoreDeleted(): static;
+}
diff --git a/src/Model.php b/src/Model.php
index 1e5a8fa..89a07cd 100644
--- a/src/Model.php
+++ b/src/Model.php
@@ -1,275 +1,365 @@
-
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\ORM;
-
-use InitORM\Database\Database;
-use InitORM\QueryBuilder\QueryBuilder;
-use ReflectionClass;
-use Throwable;
-use InitORM\Database\Facade\DB;
-use InitORM\ORM\Utils\Helper;
-use InitORM\Database\Interfaces\DatabaseInterface;
-use InitORM\DBAL\DataMapper\Interfaces\DataMapperInterface;
-use InitORM\ORM\Interfaces\EntityInterface;
-use InitORM\ORM\Interfaces\ModelInterface;
-use InitORM\ORM\Exceptions\{ModelException,
- WritableException,
- ReadableException,
- UpdatableException,
- DeletableException};
-
-/**
- * @mixin Database
- * @mixin QueryBuilder
- */
-abstract class Model implements ModelInterface
-{
-
- /**
- * @var DatabaseInterface
- */
- protected DatabaseInterface $db;
-
- protected ?array $credentials = null;
-
- protected string $schema;
-
- protected string $schemaId = 'id';
-
- protected string $entity = Entity::class;
-
- protected bool $writable = true;
-
- protected bool $readable = true;
-
- protected bool $updatable = true;
-
- protected bool $deletable = true;
-
- protected ?string $createdField = null;
-
- protected ?string $updatedField = null;
-
- protected bool $useSoftDeletes = false;
-
- protected ?string $deletedField = null;
-
- protected string $timestampFormat = 'Y-m-d H:i:s';
-
- private bool $isOnlyDelete = false;
-
- /**
- * @throws Throwable
- */
- public function __construct()
- {
- if (!isset($this->schema)) {
- $modelClass = get_called_class();
- $modelReflection = new ReflectionClass($modelClass);
- $this->schema = Helper::camelCaseToSnakeCase($modelReflection->getShortName());
- unset($modelClass, $modelReflection);
- }
- if ($this->useSoftDeletes !== false && empty($this->deletedField)) {
- throw new ModelException('There must be a delete column to use soft delete.');
- }
-
- $this->db = empty($this->credentials) ? DB::getDatabase() : DB::connect($this->credentials);
- }
-
- public function __call(string $name, array $arguments)
- {
- $res = $this->db->{$name}(...$arguments);
-
- return ($res instanceof DatabaseInterface) ? $this : $res;
- }
-
- /**
- * @inheritDoc
- */
- public function getSchema(): string
- {
- return $this->schema;
- }
-
- /**
- * @inheritDoc
- */
- public function getSchemaId(): string
- {
- return $this->schemaId;
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function create(array $set = []): bool
- {
- if (!$this->writable) {
- throw new WritableException();
- }
-
- !empty($this->createdField) && $set[$this->createdField] = date($this->timestampFormat);
-
- return $this->db->create($this->schema, $set);
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function createBatch(array $set = []): bool
- {
- if (!$this->writable) {
- throw new WritableException();
- }
- $createdField = $this->createdField;
- if (!empty($createdField) && !empty($set)) {
- foreach ($set as &$row) {
- $row[$createdField] = date($this->timestampFormat);
- }
- }
-
- return $this->db->createBatch($this->schema, $set);
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function read(array $selector = [], array $conditions = []): DataMapperInterface
- {
- if (!$this->readable) {
- throw new ReadableException();
- }
- if ($this->useSoftDeletes) {
- if ($this->isOnlyDelete) {
- $this->onlyDeleted();
- } else {
- $this->ignoreDeleted();
- }
- $this->isOnlyDelete = false;
- }
-
- return $this->db
- ->read($this->schema, $selector, $conditions)
- ->asClass($this->entity);
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function update(array $set = []): bool
- {
- if (!$this->updatable) {
- throw new UpdatableException();
- }
-
- if (!empty($this->schemaId) && isset($set[$this->schemaId])) {
- $this->db->where($this->schemaId, $set[$this->schemaId]);
- unset($set[$this->schemaId]);
- }
-
- !empty($this->updatedField) && $set[$this->updatedField] = date($this->timestampFormat);
-
- $this->ignoreDeleted();
-
- return $this->db->update($this->schema, $set);
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function updateBatch(array $set = [], ?string $referenceColumn = null): bool
- {
- if (!$this->updatable) {
- throw new UpdatableException();
- }
- $updatedField = $this->updatedField;
- if (!empty($updatedField) && !empty($set)) {
- foreach ($set as &$row) {
- $row[$updatedField] = date($this->timestampFormat);
- }
- }
- $this->ignoreDeleted();
-
- return $this->db->updateBatch($referenceColumn ?? $this->schemaId, $this->schema, $set);
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function delete(?array $conditions = null, bool $purge = false): bool
- {
- if (!$this->deletable) {
- throw new DeletableException();
- }
- if ($this->useSoftDeletes && $purge === false) {
- $this->ignoreDeleted()
- ->set($this->deletedField, date($this->timestampFormat));
-
- if (!empty($conditions)) {
- foreach ($conditions as $column => $value) {
- if (is_string($column)) {
- $this->db->where($column, $value);
- } else {
- $this->db->where($value);
- }
- }
- }
-
- return $this->db->update($this->schema);
- }
-
- return $this->db->delete($this->schema, $conditions);
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function save(EntityInterface $entity): bool
- {
- $data = $entity->toArray();
-
- return !empty($this->schemaId) && isset($data[$this->schemaId]) ? $this->update($data) : $this->create($data);
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function onlyDeleted(): self
- {
- $this->useSoftDeletes && $this->db->whereIsNotNull($this->deletedField);
-
- return $this;
- }
-
- /**
- * @inheritDoc
- * @throws Throwable
- */
- public function ignoreDeleted(): self
- {
- $this->useSoftDeletes && $this->db->whereIsNull($this->deletedField);
-
- return $this;
- }
-
-}
+limit(...)->read()` works because both `where` and
+ * `limit` return the Model.
+ *
+ * @mixin DatabaseInterface
+ */
+abstract class Model implements ModelInterface
+{
+ protected DatabaseInterface $db;
+
+ /**
+ * Optional standalone connection credentials. When null, the model
+ * binds to the shared {@see DB::getDatabase()} facade instance.
+ *
+ * @var array|null
+ */
+ protected ?array $credentials = null;
+
+ /**
+ * Backing table name. Auto-derived from the subclass short name via
+ * {@see Helper::camelCaseToSnakeCase()} when left unset.
+ */
+ protected string $schema;
+
+ /**
+ * Primary-key column. Used by {@see self::update()} to lift the PK out
+ * of the SET map into a WHERE clause and by {@see self::save()} to
+ * decide between insert and update.
+ */
+ protected string $schemaId = 'id';
+
+ /**
+ * Entity class used to hydrate read() results. Must implement
+ * {@see EntityInterface} (otherwise hydration is delegated to PDO's
+ * FETCH_CLASS which still works, but the contract is loosened).
+ *
+ * @var class-string
+ */
+ protected string $entity = Entity::class;
+
+ protected bool $writable = true;
+
+ protected bool $readable = true;
+
+ protected bool $updatable = true;
+
+ protected bool $deletable = true;
+
+ /**
+ * Column name auto-filled with `date($timestampFormat)` on each create.
+ * Disabled when null.
+ */
+ protected ?string $createdField = null;
+
+ /**
+ * Column name auto-filled with `date($timestampFormat)` on each update.
+ * Disabled when null.
+ */
+ protected ?string $updatedField = null;
+
+ /**
+ * When true, {@see self::delete()} sets {@see self::$deletedField}
+ * instead of issuing a DELETE; reads filter out rows whose
+ * {@see self::$deletedField} is non-null. {@see self::$deletedField} is
+ * required when this is on (enforced in the constructor).
+ */
+ protected bool $useSoftDeletes = false;
+
+ /**
+ * Column name used to mark a row as soft-deleted (must be nullable in
+ * the underlying schema). Required when {@see self::$useSoftDeletes} is
+ * true.
+ */
+ protected ?string $deletedField = null;
+
+ /**
+ * `date()` format string used for created / updated / deleted columns.
+ */
+ protected string $timestampFormat = 'Y-m-d H:i:s';
+
+ /**
+ * One-shot scope flag set by {@see self::onlyDeleted()}; consumed by the
+ * next {@see self::read()} call.
+ */
+ private bool $isOnlyDeleted = false;
+
+ /**
+ * @throws ModelException When {@see self::$useSoftDeletes} is on but no
+ * {@see self::$deletedField} is configured.
+ */
+ public function __construct()
+ {
+ if (!isset($this->schema)) {
+ $shortName = (new ReflectionClass($this))->getShortName();
+ $this->schema = Helper::camelCaseToSnakeCase($shortName);
+ }
+
+ if ($this->useSoftDeletes && empty($this->deletedField)) {
+ throw new ModelException(sprintf(
+ '%s has $useSoftDeletes enabled but $deletedField is not configured.',
+ static::class
+ ));
+ }
+
+ $this->db = $this->credentials === null
+ ? DB::getDatabase()
+ : DB::connect($this->credentials);
+ }
+
+ /**
+ * Forward unknown calls to the inner {@see DatabaseInterface}. Chainable
+ * calls (those the Database forwards back as itself) re-wrap to return
+ * this Model so fluent chains continue across the wrapper boundary.
+ *
+ * @param array $arguments
+ *
+ * @throws BadMethodCallException When the method does not exist on the
+ * underlying Database or query builder.
+ */
+ public function __call(string $name, array $arguments): mixed
+ {
+ if (!method_exists($this->db, $name)) {
+ // Database itself forwards to the builder via __call; we can't
+ // method_exists() the builder transparently, so let Database
+ // decide and surface its DatabaseException as-is. The presence
+ // check is best-effort for the direct Database surface.
+ try {
+ $result = $this->db->{$name}(...$arguments);
+ } catch (\Throwable $e) {
+ throw new BadMethodCallException(
+ sprintf('Method "%s::%s" does not exist.', static::class, $name),
+ 0,
+ $e
+ );
+ }
+ } else {
+ $result = $this->db->{$name}(...$arguments);
+ }
+
+ return $result instanceof DatabaseInterface ? $this : $result;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSchema(): string
+ {
+ return $this->schema;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getSchemaId(): string
+ {
+ return $this->schemaId;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function getDatabase(): DatabaseInterface
+ {
+ return $this->db;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function create(array $set = []): bool
+ {
+ if (!$this->writable) {
+ throw new WritableException(sprintf('%s is not writable.', static::class));
+ }
+
+ if ($this->createdField !== null && $this->createdField !== '') {
+ $set[$this->createdField] = date($this->timestampFormat);
+ }
+
+ return $this->db->create($this->schema, $set);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function createBatch(array $set = []): bool
+ {
+ if (!$this->writable) {
+ throw new WritableException(sprintf('%s is not writable.', static::class));
+ }
+
+ if ($this->createdField !== null && $this->createdField !== '' && !empty($set)) {
+ $now = date($this->timestampFormat);
+ foreach ($set as &$row) {
+ $row[$this->createdField] = $now;
+ }
+ unset($row);
+ }
+
+ return $this->db->createBatch($this->schema, $set);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function read(array $selector = [], array $conditions = []): DataMapperInterface
+ {
+ if (!$this->readable) {
+ throw new ReadableException(sprintf('%s is not readable.', static::class));
+ }
+
+ if ($this->useSoftDeletes) {
+ if ($this->isOnlyDeleted) {
+ $this->db->whereIsNotNull($this->deletedField);
+ $this->isOnlyDeleted = false;
+ } else {
+ $this->db->whereIsNull($this->deletedField);
+ }
+ }
+
+ return $this->db
+ ->read($this->schema, $selector, $conditions)
+ ->asClass($this->entity);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function update(array $set = [], ?array $conditions = null): bool
+ {
+ if (!$this->updatable) {
+ throw new UpdatableException(sprintf('%s is not updatable.', static::class));
+ }
+
+ if ($this->schemaId !== '' && isset($set[$this->schemaId])) {
+ $this->db->where($this->schemaId, '=', $set[$this->schemaId]);
+ unset($set[$this->schemaId]);
+ }
+
+ if ($this->updatedField !== null && $this->updatedField !== '') {
+ $set[$this->updatedField] = date($this->timestampFormat);
+ }
+
+ if ($this->useSoftDeletes) {
+ $this->db->whereIsNull($this->deletedField);
+ }
+
+ return $this->db->update($this->schema, $set, $conditions);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function updateBatch(array $set = [], ?string $referenceColumn = null): bool
+ {
+ if (!$this->updatable) {
+ throw new UpdatableException(sprintf('%s is not updatable.', static::class));
+ }
+
+ if ($this->updatedField !== null && $this->updatedField !== '' && !empty($set)) {
+ $now = date($this->timestampFormat);
+ foreach ($set as &$row) {
+ $row[$this->updatedField] = $now;
+ }
+ unset($row);
+ }
+
+ if ($this->useSoftDeletes) {
+ $this->db->whereIsNull($this->deletedField);
+ }
+
+ return $this->db->updateBatch($referenceColumn ?? $this->schemaId, $this->schema, $set);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function delete(?array $conditions = null, bool $purge = false): bool
+ {
+ if (!$this->deletable) {
+ throw new DeletableException(sprintf('%s is not deletable.', static::class));
+ }
+
+ if ($this->useSoftDeletes && !$purge) {
+ $this->db
+ ->whereIsNull($this->deletedField)
+ ->set($this->deletedField, date($this->timestampFormat));
+
+ return $this->db->update($this->schema, null, $conditions);
+ }
+
+ return $this->db->delete($this->schema, $conditions);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function save(EntityInterface $entity): bool
+ {
+ $data = $entity->toArray();
+
+ $hasId = $this->schemaId !== ''
+ && isset($data[$this->schemaId])
+ && $data[$this->schemaId] !== null
+ && $data[$this->schemaId] !== '';
+
+ return $hasId ? $this->update($data) : $this->create($data);
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function onlyDeleted(): static
+ {
+ $this->isOnlyDeleted = true;
+
+ return $this;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ public function ignoreDeleted(): static
+ {
+ if ($this->useSoftDeletes) {
+ $this->db->whereIsNull($this->deletedField);
+ }
+
+ return $this;
+ }
+}
diff --git a/src/Utils/Helper.php b/src/Utils/Helper.php
index 2a248e3..0c0df2e 100644
--- a/src/Utils/Helper.php
+++ b/src/Utils/Helper.php
@@ -1,54 +1,62 @@
-
- * @copyright Copyright © 2023 Muhammet ŞAFAK
- * @license ./LICENSE MIT
- * @version 1.0
- * @link https://www.muhammetsafak.com.tr
- */
-
-declare(strict_types=1);
-namespace InitORM\ORM\Utils;
-
-final class Helper
-{
-
- /**
- * @param string $string
- * @return string
- */
- public static function camelCaseToSnakeCase(string $string): string
- {
- $string = lcfirst($string);
- $split = preg_split('', $string, -1, PREG_SPLIT_NO_EMPTY);
- $snake_case = '';
- $i = 0;
- foreach ($split as $row) {
- $snake_case .= ($i === 0 ? '_' : '')
- . strtolower($row);
- ++$i;
- }
-
- return lcfirst($snake_case);
- }
-
- /**
- * @param string $string
- * @return string
- */
- public static function snakeCaseToPascalCase(string $string): string
- {
- $split = explode('_', strtolower($string));
- $camelCase = '';
- foreach ($split as $row) {
- $camelCase .= ucfirst($row);
- }
-
- return $camelCase;
- }
-
-}
+col = ...) does not trigger
+ * itself recursively.
+ * - syncOriginal() must run AFTER the initial fill so the original
+ * snapshot reflects the construction-time data, not an empty array.
+ */
+final class EntityTest extends TestCase
+{
+ public function test_attributes_can_be_read_and_written_via_property_syntax(): void
+ {
+ $entity = new Entity(['title' => 'first']);
+
+ self::assertSame('first', $entity->title);
+
+ $entity->body = 'lorem';
+ self::assertSame('lorem', $entity->body);
+ }
+
+ public function test_accessor_receives_stored_value(): void
+ {
+ $entity = new PostEntity(['title' => 'first']);
+
+ // setTitleAttribute trims; getTitleAttribute upper-cases.
+ self::assertSame('FIRST', $entity->title);
+ }
+
+ public function test_accessor_returns_null_when_attribute_absent(): void
+ {
+ $entity = new PostEntity();
+
+ self::assertNull($entity->title);
+ }
+
+ public function test_mutator_routes_through_set_attribute(): void
+ {
+ $entity = new PostEntity();
+ $entity->title = ' padded ';
+
+ self::assertSame('PADDED', $entity->title); // trimmed by mutator, upper-cased by accessor
+ self::assertSame('padded', $entity->getAttribute('title'));
+ }
+
+ public function test_mutator_with_set_attribute_helper(): void
+ {
+ $entity = new PostEntity();
+ $entity->body = ' some text ';
+
+ self::assertSame('some text', $entity->body);
+ }
+
+ public function test_to_array_and_get_attributes_return_attribute_bag(): void
+ {
+ $entity = new Entity(['a' => 1, 'b' => 2]);
+
+ self::assertSame(['a' => 1, 'b' => 2], $entity->toArray());
+ self::assertSame(['a' => 1, 'b' => 2], $entity->getAttributes());
+ }
+
+ public function test_isset_and_unset(): void
+ {
+ $entity = new Entity(['a' => 1]);
+
+ self::assertTrue(isset($entity->a));
+ self::assertFalse(isset($entity->b));
+
+ unset($entity->a);
+ self::assertFalse(isset($entity->a));
+ }
+
+ public function test_debug_info_returns_attributes(): void
+ {
+ $entity = new Entity(['a' => 1]);
+
+ self::assertSame(['a' => 1], $entity->__debugInfo());
+ }
+
+ public function test_sync_original_captures_constructor_data(): void
+ {
+ $entity = new Entity(['a' => 1, 'b' => 2]);
+ $entity->a = 99;
+
+ self::assertSame(['a' => 1, 'b' => 2], $entity->getOriginal());
+ self::assertSame(['a' => 99, 'b' => 2], $entity->toArray());
+ }
+
+ public function test_sync_original_can_be_re_called(): void
+ {
+ $entity = new Entity(['a' => 1]);
+ $entity->a = 99;
+ $entity->syncOriginal();
+
+ self::assertSame(['a' => 99], $entity->getOriginal());
+ }
+
+ public function test_get_set_attribute_helpers_bypass_magic_hooks(): void
+ {
+ $entity = new PostEntity();
+ $entity->setAttribute('title', 'raw');
+
+ self::assertSame('raw', $entity->getAttribute('title'));
+ // Reading via property still routes through the accessor:
+ self::assertSame('RAW', $entity->title);
+ }
+
+ public function test_call_default_get_attribute_method(): void
+ {
+ $entity = new Entity(['post_title' => 'first']);
+
+ self::assertSame('first', $entity->getPostTitleAttribute());
+ }
+
+ public function test_call_default_set_attribute_method(): void
+ {
+ $entity = new Entity();
+ $entity->setPostTitleAttribute('first');
+
+ self::assertSame('first', $entity->getAttribute('post_title'));
+ }
+
+ public function test_call_throws_for_unknown_method(): void
+ {
+ $entity = new Entity();
+
+ $this->expectException(EntityException::class);
+ /** @phpstan-ignore-next-line — exercising the failure path. */
+ $entity->doSomething();
+ }
+}
diff --git a/tests/HelperTest.php b/tests/HelperTest.php
new file mode 100644
index 0000000..778463d
--- /dev/null
+++ b/tests/HelperTest.php
@@ -0,0 +1,74 @@
+
+ */
+ public static function camelToSnakeProvider(): array
+ {
+ return [
+ 'single lowercase' => ['posts', 'posts'],
+ 'single PascalCase' => ['Posts', 'posts'],
+ 'two words PascalCase' => ['PostCategory', 'post_category'],
+ 'three words' => ['PostCategoryTag', 'post_category_tag'],
+ 'acronym + word' => ['XMLParser', 'xml_parser'],
+ 'long acronym' => ['HTTPRequest', 'http_request'],
+ 'already snake' => ['post_title', 'post_title'],
+ 'digit boundary' => ['User2Login', 'user2_login'],
+ ];
+ }
+
+ #[DataProvider('camelToSnakeProvider')]
+ public function test_camel_case_to_snake_case_handles_real_identifiers(string $input, string $expected): void
+ {
+ self::assertSame($expected, Helper::camelCaseToSnakeCase($input));
+ }
+
+ /**
+ * @return array
+ */
+ public static function snakeToPascalProvider(): array
+ {
+ return [
+ 'single' => ['posts', 'Posts'],
+ 'two segments' => ['post_title', 'PostTitle'],
+ 'three segments' => ['post_category_tag', 'PostCategoryTag'],
+ 'already pascal' => ['PostTitle', 'Posttitle'],
+ 'leading underscore' => ['_hidden', 'Hidden'],
+ ];
+ }
+
+ #[DataProvider('snakeToPascalProvider')]
+ public function test_snake_case_to_pascal_case_round_trips(string $input, string $expected): void
+ {
+ self::assertSame($expected, Helper::snakeCaseToPascalCase($input));
+ }
+
+ public function test_round_trip_snake_pascal_snake(): void
+ {
+ $snake = 'post_category_tag';
+
+ self::assertSame(
+ $snake,
+ Helper::camelCaseToSnakeCase(Helper::snakeCaseToPascalCase($snake))
+ );
+ }
+}
diff --git a/tests/ModelCallForwardingTest.php b/tests/ModelCallForwardingTest.php
new file mode 100644
index 0000000..c0c91a2
--- /dev/null
+++ b/tests/ModelCallForwardingTest.php
@@ -0,0 +1,50 @@
+where('id', '=', 1);
+
+ self::assertSame($model, $result);
+ }
+
+ public function test_chained_builder_calls_compose_into_read(): void
+ {
+ $model = new PostModel();
+
+ $rows = $model
+ ->where('title', '=', 'First')
+ ->read()
+ ->rows();
+
+ self::assertCount(1, $rows);
+ }
+
+ public function test_unknown_method_raises_bad_method_call(): void
+ {
+ $model = new PostModel();
+
+ $this->expectException(BadMethodCallException::class);
+ /** @phpstan-ignore-next-line — exercising the failure path. */
+ $model->thisMethodDoesNotExist();
+ }
+}
diff --git a/tests/ModelConstructionTest.php b/tests/ModelConstructionTest.php
new file mode 100644
index 0000000..868259a
--- /dev/null
+++ b/tests/ModelConstructionTest.php
@@ -0,0 +1,56 @@
+getSchema());
+ self::assertSame('id', $model->getSchemaId());
+ }
+
+ public function test_schema_is_auto_derived_from_short_name(): void
+ {
+ SqliteHelper::seedTags($this->connection);
+
+ $model = new TagModel();
+
+ // "TagModel" → "tag_model" — exercises Helper::camelCaseToSnakeCase.
+ self::assertSame('tag_model', $model->getSchema());
+ }
+
+ public function test_soft_delete_without_deleted_field_throws(): void
+ {
+ $this->expectException(ModelException::class);
+ $this->expectExceptionMessageMatches('/\$deletedField/');
+
+ new class () extends Model {
+ protected string $schema = 'posts';
+ protected bool $useSoftDeletes = true;
+ // deletedField intentionally left unset
+ };
+ }
+
+ public function test_model_uses_db_facade_by_default(): void
+ {
+ $model = new PostModel();
+
+ self::assertSame($this->db, $model->getDatabase());
+ }
+}
diff --git a/tests/ModelCrudTest.php b/tests/ModelCrudTest.php
new file mode 100644
index 0000000..2a65ff1
--- /dev/null
+++ b/tests/ModelCrudTest.php
@@ -0,0 +1,149 @@
+create(['title' => 'Fresh', 'body' => 'body4']);
+
+ self::assertTrue($ok);
+
+ $rows = $this->db->table('posts')->read()->asAssoc()->rows();
+ self::assertCount(4, $rows);
+
+ $inserted = end($rows);
+ self::assertSame('Fresh', $inserted['title']);
+ self::assertNotNull($inserted['created_at']);
+ }
+
+ public function test_create_batch_fills_created_field_on_every_row(): void
+ {
+ $model = new PostModel();
+
+ $ok = $model->createBatch([
+ ['title' => 'B1', 'body' => 'b1'],
+ ['title' => 'B2', 'body' => 'b2'],
+ ]);
+
+ self::assertTrue($ok);
+
+ $rows = $this->db->table('posts')->read()->asAssoc()->rows();
+ self::assertCount(5, $rows);
+ foreach (array_slice($rows, -2) as $row) {
+ self::assertNotNull($row['created_at']);
+ }
+ }
+
+ public function test_read_hydrates_entity_class(): void
+ {
+ $model = new PostModel();
+ $result = $model->read();
+
+ $first = $result->row();
+ self::assertInstanceOf(PostEntity::class, $first);
+ }
+
+ public function test_read_filters_soft_deleted_by_default(): void
+ {
+ $model = new PostModel();
+
+ $rows = $model->read()->rows();
+
+ // Three rows seeded; one is soft-deleted.
+ self::assertCount(2, $rows);
+ }
+
+ public function test_update_lifts_primary_key_into_where_clause(): void
+ {
+ $model = new PostModel();
+
+ $ok = $model->update(['id' => 1, 'title' => 'Renamed']);
+
+ self::assertTrue($ok);
+
+ $row = $this->db
+ ->table('posts')
+ ->where('id', '=', 1)
+ ->read()
+ ->asAssoc()
+ ->row();
+
+ self::assertSame('Renamed', $row['title']);
+ self::assertNotNull($row['updated_at']);
+
+ // Second row must not have been touched.
+ $row2 = $this->db
+ ->table('posts')
+ ->where('id', '=', 2)
+ ->read()
+ ->asAssoc()
+ ->row();
+ self::assertSame('Second', $row2['title']);
+ }
+
+ public function test_update_accepts_explicit_conditions(): void
+ {
+ $model = new PostModel();
+
+ $ok = $model->update(['title' => 'Updated'], ['id' => 2]);
+
+ self::assertTrue($ok);
+
+ $row = $this->db
+ ->table('posts')
+ ->where('id', '=', 2)
+ ->read()
+ ->asAssoc()
+ ->row();
+
+ self::assertSame('Updated', $row['title']);
+ }
+
+ public function test_delete_soft_deletes_by_default(): void
+ {
+ $model = new PostModel();
+
+ $ok = $model->delete(['id' => 1]);
+
+ self::assertTrue($ok);
+
+ // The row is still in the table, but `deleted_at` is now set.
+ $row = $this->db
+ ->table('posts')
+ ->where('id', '=', 1)
+ ->read()
+ ->asAssoc()
+ ->row();
+
+ self::assertNotNull($row);
+ self::assertNotNull($row['deleted_at']);
+ }
+
+ public function test_delete_with_purge_removes_row(): void
+ {
+ $model = new PostModel();
+
+ $ok = $model->delete(['id' => 1], purge: true);
+
+ self::assertTrue($ok);
+
+ $row = $this->db
+ ->table('posts')
+ ->where('id', '=', 1)
+ ->read()
+ ->asAssoc()
+ ->row();
+
+ self::assertNull($row);
+ }
+}
diff --git a/tests/ModelGateTest.php b/tests/ModelGateTest.php
new file mode 100644
index 0000000..fcfe677
--- /dev/null
+++ b/tests/ModelGateTest.php
@@ -0,0 +1,71 @@
+expectException(WritableException::class);
+ $model->create(['title' => 'x']);
+ }
+
+ public function test_writable_false_blocks_create_batch(): void
+ {
+ $model = new NonWritablePostModel();
+
+ $this->expectException(WritableException::class);
+ $model->createBatch([['title' => 'x']]);
+ }
+
+ public function test_readable_false_blocks_read(): void
+ {
+ $model = new NonReadablePostModel();
+
+ $this->expectException(ReadableException::class);
+ $model->read();
+ }
+
+ public function test_updatable_false_blocks_update(): void
+ {
+ $model = new NonUpdatablePostModel();
+
+ $this->expectException(UpdatableException::class);
+ $model->update(['title' => 'x']);
+ }
+
+ public function test_updatable_false_blocks_update_batch(): void
+ {
+ $model = new NonUpdatablePostModel();
+
+ $this->expectException(UpdatableException::class);
+ $model->updateBatch([['id' => 1, 'title' => 'x']]);
+ }
+
+ public function test_deletable_false_blocks_delete(): void
+ {
+ $model = new NonDeletablePostModel();
+
+ $this->expectException(DeletableException::class);
+ $model->delete(['id' => 1]);
+ }
+}
diff --git a/tests/ModelSaveTest.php b/tests/ModelSaveTest.php
new file mode 100644
index 0000000..c1d4d7d
--- /dev/null
+++ b/tests/ModelSaveTest.php
@@ -0,0 +1,45 @@
+ 'New', 'body' => 'nb']);
+
+ self::assertTrue($model->save($entity));
+
+ $rows = $this->db->table('posts')->read()->asAssoc()->rows();
+ self::assertCount(4, $rows);
+ }
+
+ public function test_save_updates_when_id_is_set(): void
+ {
+ $model = new PostModel();
+ $entity = new PostEntity(['id' => 1, 'title' => 'Renamed', 'body' => 'b1']);
+
+ self::assertTrue($model->save($entity));
+
+ $row = $this->db
+ ->table('posts')
+ ->where('id', '=', 1)
+ ->read()
+ ->asAssoc()
+ ->row();
+
+ self::assertSame('Renamed', $row['title']);
+ }
+}
diff --git a/tests/ModelSoftDeleteTest.php b/tests/ModelSoftDeleteTest.php
new file mode 100644
index 0000000..c6d7032
--- /dev/null
+++ b/tests/ModelSoftDeleteTest.php
@@ -0,0 +1,67 @@
+onlyDeleted()->read()->rows();
+
+ // The fixture seeds exactly one soft-deleted row.
+ self::assertCount(1, $rows);
+ self::assertSame('THIRD', $rows[0]->title); // upper-cased by accessor
+ }
+
+ public function test_only_deleted_flag_is_consumed_after_one_read(): void
+ {
+ $model = new PostModel();
+
+ $model->onlyDeleted()->read()->rows();
+ $rows = $model->read()->rows(); // back to default scope
+
+ self::assertCount(2, $rows);
+ }
+
+ public function test_ignore_deleted_is_default_for_read(): void
+ {
+ $model = new PostModel();
+
+ $rows = $model->read()->rows();
+
+ // Two non-deleted out of three seeded rows.
+ self::assertCount(2, $rows);
+ }
+
+ public function test_update_with_soft_deletes_skips_already_deleted_rows(): void
+ {
+ $model = new PostModel();
+
+ // Try to update the soft-deleted row by PK — should affect 0 rows.
+ $model->update(['id' => 3, 'title' => 'Resurrected']);
+
+ $row = $this->db
+ ->table('posts')
+ ->where('id', '=', 3)
+ ->read()
+ ->asAssoc()
+ ->row();
+
+ self::assertSame('Third', $row['title']);
+ }
+}
diff --git a/tests/Support/AbstractModelTestCase.php b/tests/Support/AbstractModelTestCase.php
new file mode 100644
index 0000000..ed188e4
--- /dev/null
+++ b/tests/Support/AbstractModelTestCase.php
@@ -0,0 +1,41 @@
+connection = SqliteHelper::makeConnection();
+ SqliteHelper::seedPosts($this->connection);
+
+ $this->db = new Database($this->connection);
+ DB::replaceImmutable($this->db);
+ }
+
+ protected function tearDown(): void
+ {
+ DB::replaceImmutable(null);
+
+ parent::tearDown();
+ }
+}
diff --git a/tests/Support/Fixtures/NonDeletablePostModel.php b/tests/Support/Fixtures/NonDeletablePostModel.php
new file mode 100644
index 0000000..6a9111f
--- /dev/null
+++ b/tests/Support/Fixtures/NonDeletablePostModel.php
@@ -0,0 +1,10 @@
+col = ...` would create a dynamic
+ * property and bypass the attribute bag entirely.
+ */
+class PostEntity extends Entity
+{
+ /**
+ * Accessor — receives the stored value and returns the transformed
+ * form. {@see Entity::__get()} passes the current attribute as the
+ * sole argument.
+ */
+ public function getTitleAttribute(mixed $value): mixed
+ {
+ return is_string($value) ? strtoupper($value) : $value;
+ }
+
+ public function setTitleAttribute(mixed $value): void
+ {
+ $this->setAttribute('title', is_string($value) ? trim($value) : $value);
+ }
+
+ public function setBodyAttribute(mixed $value): void
+ {
+ $this->setAttribute('body', is_string($value) ? trim($value) : $value);
+ }
+}
diff --git a/tests/Support/Fixtures/PostModel.php b/tests/Support/Fixtures/PostModel.php
new file mode 100644
index 0000000..79101be
--- /dev/null
+++ b/tests/Support/Fixtures/PostModel.php
@@ -0,0 +1,28 @@
+ $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 seedPosts(ConnectionInterface $connection): void
+ {
+ $pdo = $connection->getPDO();
+ $pdo->exec(
+ 'CREATE TABLE posts (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ body TEXT,
+ created_at TEXT,
+ updated_at TEXT,
+ deleted_at TEXT
+ )'
+ );
+ $pdo->exec(
+ "INSERT INTO posts (title, body, created_at, deleted_at) VALUES
+ ('First', 'first body', '2024-01-01 00:00:00', NULL),
+ ('Second', 'second body', '2024-01-02 00:00:00', NULL),
+ ('Third', 'third body', '2024-01-03 00:00:00', '2024-02-01 00:00:00')"
+ );
+ }
+
+ public static function seedTags(ConnectionInterface $connection): void
+ {
+ $pdo = $connection->getPDO();
+ $pdo->exec(
+ 'CREATE TABLE tags (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT NOT NULL
+ )'
+ );
+ }
+}