# Pairity A partitioned‑model PHP ORM (DTO/DAO) with Query Builder, relations, raw SQL helpers, and a portable migrations + schema builder. Namespace: `Pairity\`. Package: `getphred/pairity`. ## Contributing This is an early foundation. Contributions, discussions, and design proposals are welcome. Please open an issue to coordinate larger features. ## License MIT ## Installation - Requirements: PHP >= 8.1, PDO extension for your database(s) - Install via Composer: ``` composer require getphred/pairity ``` After install, you can use the CLI at `vendor/bin/pairity`. ## Quick start Minimal example with SQLite (file db.sqlite) and a simple `users` DAO/DTO. ```php use Pairity\Database\ConnectionManager; use Pairity\Model\AbstractDto; use Pairity\Model\AbstractDao; // 1) Connect $conn = ConnectionManager::make([ 'driver' => 'sqlite', 'path' => __DIR__ . '/db.sqlite', ]); // 2) Ensure table exists (demo) $conn->execute('CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL, name TEXT NULL, status TEXT NULL )'); // 3) Define DTO + DAO class UserDto extends AbstractDto {} class UserDao extends AbstractDao { public function getTable(): string { return 'users'; } protected function dtoClass(): string { return UserDto::class; } } // 4) CRUD $dao = new UserDao($conn); $created = $dao->insert(['email' => 'a@b.com', 'name' => 'Alice', 'status' => 'active']); $one = $dao->findById($created->toArray()['id']); $many = $dao->findAllBy(['status' => 'active']); $dao->update($created->toArray()['id'], ['name' => 'Alice Updated']); $dao->deleteById($created->toArray()['id']); ``` For MySQL, configure: ```php $conn = ConnectionManager::make([ 'driver' => 'mysql', 'host' => '127.0.0.1', 'port' => 3306, 'database' => 'app', 'username' => 'root', 'password' => 'secret', 'charset' => 'utf8mb4', ]); ``` ## Concepts - DTO (Data Transfer Object): a lightweight data bag. Extend `Pairity\Model\AbstractDto`. Convert to arrays via `toArray(bool $deep = true)`. - DAO (Data Access Object): table‑focused persistence and relations. Extend `Pairity\Model\AbstractDao` and implement: - `getTable(): string` - `dtoClass(): string` (class-string of your DTO) - Optional: `schema()` for casts, timestamps, soft deletes - Optional: `relations()` for `hasOne`/`hasMany`/`belongsTo` - Relations are DAO‑centric: call `with([...])` to eager load; `load()`/`loadMany()` for lazy. - Field projection via `fields('id', 'name', 'posts.title')` with dot‑notation for related selects. - Raw SQL: use `ConnectionInterface::query`, `execute`, `transaction`, `lastInsertId`. - Query Builder: a simple builder (`Pairity\Query\QueryBuilder`) exists for ad‑hoc SQL composition. ## Dynamic DAO methods AbstractDao supports dynamic helpers, mapped to column names (Studly/camel to snake_case): - `findOneBy($value): ?DTO` - `findAllBy($value): DTO[]` - `updateBy($value, array $data): int` (returns affected rows) - `deleteBy($value): int` Examples: ```php $user = $dao->findOneByEmail('a@b.com'); $actives = $dao->findAllByStatus('active'); $dao->updateByEmail('a@b.com', ['name' => 'New Name']); $dao->deleteByEmail('gone@b.com'); ``` ## Selecting fields - Default projection is `SELECT *`. - Use `fields(...$fields)` to limit columns. You can include relation fields using dot‑notation: ```php $users = (new UserDao($conn)) ->fields('id', 'name', 'posts.title') ->with(['posts']) ->findAllBy(['status' => 'active']); ``` Notes: - `fields()` affects only the next `find*` call and then resets. - Relation field selections are passed to the related DAO when eager loading. ## Supported databases - MySQL/MariaDB - SQLite - PostgreSQL - SQL Server - Oracle NoSQL: - MongoDB (production): `Pairity\NoSql\Mongo\MongoClientConnection` via `mongodb/mongodb` + `ext-mongodb`. - MongoDB (stub): `Pairity\NoSql\Mongo\MongoConnection` (in‑memory) remains for experimentation without external deps. ### MongoDB (production adapter) Pairity includes a production‑ready MongoDB adapter that wraps the official `mongodb/mongodb` library. Requirements: - PHP `ext-mongodb` (installed in PHP), and Composer dependency `mongodb/mongodb` (already required by this package). Connect using the `MongoConnectionManager`: ```php use Pairity\NoSql\Mongo\MongoConnectionManager; // Option A: Full URI $conn = MongoConnectionManager::make([ 'uri' => 'mongodb://user:pass@127.0.0.1:27017/?authSource=admin', ]); // Option B: Discrete params $conn = MongoConnectionManager::make([ 'host' => '127.0.0.1', 'port' => 27017, // 'username' => 'user', // 'password' => 'pass', // 'authSource' => 'admin', // 'replicaSet' => 'rs0', // 'tls' => false, ]); // Basic CRUD $db = 'app'; $col = 'users'; $id = $conn->insertOne($db, $col, ['email' => 'mongo@example.com', 'name' => 'Alice']); $one = $conn->find($db, $col, ['_id' => $id]); $conn->updateOne($db, $col, ['_id' => $id], ['$set' => ['name' => 'Alice Updated']]); $conn->deleteOne($db, $col, ['_id' => $id]); ``` Notes: - `_id` strings that look like 24‑hex ObjectIds are automatically converted to `ObjectId` on input; returned documents convert `ObjectId` back to strings. - Aggregation pipelines are supported via `$conn->aggregate($db, $collection, $pipeline, $options)`. - See `examples/nosql/mongo_crud.php` for a runnable demo. ## Raw SQL Use the `ConnectionInterface` behind your DAO for direct SQL. ```php use Pairity\Contracts\ConnectionInterface; // Get connection from DAO $conn = $dao->getConnection(); // SELECT $rows = $conn->query('SELECT id, email FROM users WHERE status = :s', ['s' => 'active']); // INSERT/UPDATE/DELETE $affected = $conn->execute('UPDATE users SET status = :s WHERE id = :id', ['s' => 'inactive', 'id' => 10]); // Transaction $conn->transaction(function (ConnectionInterface $db) { $db->execute('INSERT INTO logs(message) VALUES(:m)', ['m' => 'started']); // ... }); ``` ## Relations (DAO‑centric MVP) Declare relations in your DAO by overriding `relations()` and use `with()` for eager loading or `load()`/`loadMany()` for lazy loading. Example: `User hasMany Posts`, `Post belongsTo User` ```php use Pairity\Model\AbstractDto; use Pairity\Model\AbstractDao; class UserDto extends AbstractDto {} class PostDto extends AbstractDto {} class UserDao extends AbstractDao { public function getTable(): string { return 'users'; } protected function dtoClass(): string { return UserDto::class; } protected function relations(): array { return [ 'posts' => [ 'type' => 'hasMany', 'dao' => PostDao::class, 'dto' => PostDto::class, 'foreignKey' => 'user_id', // on posts 'localKey' => 'id', // on users ], ]; } } class PostDao extends AbstractDao { public function getTable(): string { return 'posts'; } protected function dtoClass(): string { return PostDto::class; } protected function relations(): array { return [ 'user' => [ 'type' => 'belongsTo', 'dao' => UserDao::class, 'dto' => UserDto::class, 'foreignKey' => 'user_id', // on posts 'otherKey' => 'id', // on users ], ]; } } $users = (new UserDao($conn)) ->fields('id', 'name', 'posts.title') ->with(['posts']) ->findAllBy(['status' => 'active']); // Lazy load a relation later $postDao = new PostDao($conn); $post = $postDao->findOneBy(['id' => 10]); $postDao->load($post, 'user'); ``` Notes: - Eager loader batches queries using `IN (...)` lookups under the hood. - Loaded relations are attached onto the DTO under the relation name (e.g., `$user->posts`). - `hasOne` is supported like `hasMany` but attaches a single DTO instead of a list. ### Join‑based eager loading (opt‑in, SQL) For single‑level relations on SQL DAOs, you can opt‑in to a join‑based eager loading strategy that fetches parent and related rows in one query using `LEFT JOIN`s. Usage: ```php // Require explicit projection for related fields when joining $users = (new UserDao($conn)) ->fields('id', 'name', 'posts.title') ->useJoinEager() // opt‑in for the next call ->with(['posts']) // single‑level only in MVP ->findAllBy([]); ``` Behavior and limitations (MVP): - Single‑level only: `with(['posts'])` is supported; nested paths like `posts.comments` fall back to the default batched strategy. - You must specify the fields to load from related tables via `fields('relation.column')` so the ORM can alias columns safely. - Supported relation types: `hasOne`, `hasMany`, `belongsTo`. - `belongsToMany` continues to use the portable two‑query pivot strategy. - Soft deletes on related tables are respected by adding `... AND related.deleted_at IS NULL` to the join condition when configured in the related DAO `schema()`. - Per‑relation constraints that rely on ordering/limits aren’t applied in join mode in this MVP; prefer the default batched strategy for those cases. Tip: If join mode can’t be used (e.g., nested paths or missing relation field projections), Pairity silently falls back to the portable batched eager loader. Per‑relation hint (optional): You can hint join strategy per relation path. This is useful when you want to selectively join specific relations in a single‑level eager load. The join will be used only when safe (single‑level paths and explicit relation projections are present), otherwise Pairity falls back to the portable strategy. ```php $users = (new UserDao($conn)) ->fields('id','name','posts.title') ->with([ // Hint join for posts; you can also pass a callable under 'constraint' alongside 'strategy' 'posts' => ['strategy' => 'join'] ]) ->findAllBy([]); ``` Notes: - If you also call `useJoinEager()` or `eagerStrategy('join')`, that global setting takes precedence. - Join eager is still limited to single‑level relations in this MVP. Nested paths (e.g., `posts.comments`) will use the portable strategy. ### belongsToMany (SQL) and pivot helpers Pairity supports many‑to‑many relations for SQL DAOs via a pivot table. Declare `belongsToMany` in your DAO’s `relations()` and use the built‑in pivot helpers `attach`, `detach`, and `sync`. Relation metadata keys: - `type` = `belongsToMany` - `dao` = related DAO class - `pivot` (or `pivotTable`) = pivot table name - `foreignPivotKey` = pivot column referencing the parent table - `relatedPivotKey` = pivot column referencing the related table - `localKey` = parent primary key column (default `id`) - `relatedKey` = related primary key column (default `id`) Example (users ↔ roles): ```php class UserDao extends AbstractDao { protected function relations(): array { return [ 'roles' => [ 'type' => 'belongsToMany', 'dao' => RoleDao::class, 'pivot' => 'user_role', 'foreignPivotKey' => 'user_id', 'relatedPivotKey' => 'role_id', 'localKey' => 'id', 'relatedKey' => 'id', ], ]; } } $user = $userDao->insert(['email' => 'a@b.com']); $uid = $user->toArray(false)['id']; $userDao->attach('roles', $uid, [$roleId1, $roleId2]); // insert into pivot $userDao->detach('roles', $uid, [$roleId1]); // delete specific $userDao->sync('roles', $uid, [$roleId2]); // make roles exactly this set $with = $userDao->with(['roles'])->findById($uid); // eager load related roles ``` See `examples/mysql_relations_pivot.php` for a runnable snippet. ### Nested eager loading You can request nested eager loading using dot notation. Example: load a user’s posts and each post’s comments: ```php $users = $userDao->with(['posts.comments'])->findAllBy([...]); ``` Nested eager loading works for SQL and Mongo DAOs. Pairity performs separate batched fetches per relation level to remain portable across drivers. ### Per‑relation constraints Pass a callable per relation path to customize how the related DAO queries data for that relation. The callable receives the related DAO instance so you can specify fields, ordering, and limits. - SQL example (per‑relation `fields()` projection and ordering): ```php $users = $userDao->with([ 'posts' => function (UserPostDao $dao) { $dao->fields('id', 'title'); // $dao->orderBy('created_at DESC'); // if your DAO exposes ordering }, 'posts.comments' // nested ])->findAllBy(['status' => 'active']); ``` - Mongo example (projection, sort, limit): ```php $docs = $userMongoDao->with([ 'posts' => function (PostMongoDao $dao) { $dao->fields('title')->sort(['title' => 1])->limit(10); }, 'posts.comments' ])->findAllBy([]); ``` Constraints are applied only to the specific relation path they are defined on. ## Model metadata & schema mapping (MVP) Define schema metadata on your DAO by overriding `schema()`. The schema enables: - Column casts (storage <-> PHP): `int`, `float`, `bool`, `string`, `datetime`, `json` - Timestamps automation (`createdAt`, `updatedAt` filled automatically) - Soft deletes (update `deletedAt` instead of hard delete, with query scopes) Example: ```php use Pairity\Model\AbstractDao; class UserDao extends AbstractDao { public function getTable(): string { return 'users'; } protected function dtoClass(): string { return UserDto::class; } // Optional: declare primary key, casts, timestamps, soft deletes protected function schema(): array { return [ 'primaryKey' => 'id', 'columns' => [ 'id' => ['cast' => 'int'], 'email' => ['cast' => 'string'], 'name' => ['cast' => 'string'], 'status' => ['cast' => 'string'], // if present in your table 'data' => ['cast' => 'json'], 'created_at' => ['cast' => 'datetime'], 'updated_at' => ['cast' => 'datetime'], 'deleted_at' => ['cast' => 'datetime'], ], 'timestamps' => [ 'createdAt' => 'created_at', 'updatedAt' => 'updated_at', ], 'softDeletes' => [ 'enabled' => true, 'deletedAt' => 'deleted_at', ], ]; } } // Usage (defaults to SELECT * unless you call fields()) $users = (new UserDao($conn)) ->findAllBy(['status' => 'active']); // Soft delete vs hard delete (new UserDao($conn))->deleteById(10); // if softDeletes enabled => sets deleted_at timestamp // Query scopes for soft deletes $all = (new UserDao($conn))->withTrashed()->findAllBy(); // include soft-deleted rows $trashedOnly = (new UserDao($conn))->onlyTrashed()->findAllBy(); // only soft-deleted // Casting on hydration and storage $user = (new UserDao($conn))->findById(1); // date columns become DateTimeImmutable; json becomes array $created = (new UserDao($conn))->insert([ 'email' => 'a@b.com', 'name' => 'Alice', 'status' => 'active', 'data' => ['tags' => ['a','b']], // stored as JSON automatically ]); ``` ### Timestamps & Soft Deletes - Configure in your DAO `schema()` using keys: - `timestamps` → `['createdAt' => 'created_at', 'updatedAt' => 'updated_at']` - `softDeletes` → `['enabled' => true, 'deletedAt' => 'deleted_at']` - Behavior: - On `insert()`, both `created_at` and `updated_at` are auto-filled (UTC `Y-m-d H:i:s`). - On `update()` and `updateBy()`, `updated_at` is auto-updated. - On `deleteById()` / `deleteBy()`, if soft deletes are enabled, rows are marked by setting `deleted_at` instead of being physically removed. - Default queries exclude soft-deleted rows. Use scopes `withTrashed()` and `onlyTrashed()` to modify visibility. - Helpers: - `restoreById($id)` / `restoreBy($criteria)` — set `deleted_at` to NULL. - `forceDeleteById($id)` / `forceDeleteBy($criteria)` — permanently delete. - `touch($id)` — update only the `updated_at` column. Example: ```php $dao = new UserDao($conn); $user = $dao->insert(['email' => 'x@y.com']); // created_at/updated_at filled $dao->update($user->id, ['name' => 'Updated']); // updated_at bumped $dao->deleteById($user->id); // soft delete $also = $dao->withTrashed()->findById($user->id); // visible with trashed $dao->restoreById($user->id); // restore $dao->forceDeleteById($user->id); // permanent ``` ## Migrations & Schema Builder Pairity ships a lightweight migrations runner and a portable schema builder focused on MySQL and SQLite for v1. You can declare migrations as PHP classes implementing `Pairity\Migrations\MigrationInterface` and build tables with a fluent `Schema` builder. Supported: - Table operations: `create`, `drop`, `dropIfExists`, `table(...)` (ALTER) - Columns: `increments`, `bigIncrements`, `integer`, `bigInteger`, `string(varchar)`, `text`, `boolean`, `json`, `datetime`, `decimal(precision, scale)`, `timestamps()` - Indexes: `primary([...])`, `unique([...], ?name)`, `index([...], ?name)` - ALTER (MVP): `add column` (all drivers), `drop column` (MySQL, Postgres, SQL Server; SQLite 3.35+), `rename column` (MySQL 8+/Postgres/SQL Server; SQLite 3.25+), `add/drop index/unique`, `rename table` - Drivers: MySQL/MariaDB (default), SQLite (auto-detected), PostgreSQL (pgsql), SQL Server (sqlsrv), Oracle (oci) Example migration (see `examples/migrations/CreateUsersTable.php`): ```php use Pairity\Migrations\MigrationInterface; use Pairity\Contracts\ConnectionInterface; use Pairity\Schema\SchemaManager; use Pairity\Schema\Blueprint; return new class implements MigrationInterface { public function up(ConnectionInterface $connection): void { $schema = SchemaManager::forConnection($connection); $schema->create('users', function (Blueprint $t) { $t->increments('id'); $t->string('email', 190); $t->unique(['email']); $t->string('name', 255)->nullable(); $t->string('status', 50)->nullable(); $t->timestamps(); }); } public function down(ConnectionInterface $connection): void { $schema = SchemaManager::forConnection($connection); $schema->dropIfExists('users'); } }; ``` Running migrations (SQLite example): ```php 'sqlite', 'path' => __DIR__ . '/../db.sqlite', ]); $createUsers = require __DIR__ . '/migrations/CreateUsersTable.php'; $migrator = new Migrator($conn); $migrator->setRegistry(['CreateUsersTable' => $createUsers]); $applied = $migrator->migrate(['CreateUsersTable' => $createUsers]); echo 'Applied: ' . json_encode($applied) . PHP_EOL; ``` Notes: - The migrations runner tracks applied migrations in a `migrations` table with batches. - For rollback, keep a registry of name => instance in the same process or use class names that can be autoloaded. - SQLite has limitations around `ALTER TABLE` operations; this MVP emits native `ALTER TABLE` for supported versions (ADD COLUMN always; RENAME/DROP COLUMN require modern SQLite). For legacy versions, operations may fail; rebuild strategies can be added later. - Pairity includes a best-effort table rebuild fallback for legacy SQLite: when `DROP COLUMN`/`RENAME COLUMN` is unsupported, it recreates the table and copies data. Complex constraints/triggers may not be preserved. ### CLI Pairity ships a tiny CLI for migrations. After `composer install`, the binary is available as `vendor/bin/pairity` or as a project bin if installed as a dependency. Usage: ``` pairity migrate [--path=DIR] [--config=FILE] pairity rollback [--steps=N] [--config=FILE] pairity status [--path=DIR] [--config=FILE] pairity reset [--config=FILE] pairity make:migration Name [--path=DIR] ``` Options and environment: - If `--config=FILE` is provided, it must be a PHP file returning the ConnectionManager config array. - Otherwise, the CLI reads environment variables: - `DB_DRIVER` (mysql|mariadb|pgsql|postgres|postgresql|sqlite|sqlsrv) - `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`, `DB_CHARSET` (MySQL) - `DB_PATH` (SQLite path) - If nothing is provided, defaults to a SQLite file at `db.sqlite` in project root. Migration discovery: - The CLI looks for migrations in `./database/migrations`, then `project/database/migrations`, then `examples/migrations`. - Each PHP file should `return` a `MigrationInterface` instance (see examples). Files are applied in filename order. Example ALTER migration (users add bio): ```php return new class implements MigrationInterface { public function up(ConnectionInterface $connection): void { $schema = SchemaManager::forConnection($connection); $schema->table('users', function (Blueprint $t) { $t->string('bio', 500)->nullable(); $t->index(['status'], 'users_status_index'); }); } public function down(ConnectionInterface $connection): void { $schema = SchemaManager::forConnection($connection); $schema->table('users', function (Blueprint $t) { $t->dropIndex('users_status_index'); $t->dropColumn('bio'); }); } }; ``` Notes: - Schema is optional; if omitted, DAOs behave as before (no casting, no timestamps/soft deletes). - Timestamps use UTC and the format `Y-m-d H:i:s` for portability. - Default `SELECT` is `*`. To limit columns, use `fields()`; it always takes precedence. ## DTO toArray (deep vs shallow) DTOs implement `toArray(bool $deep = true)`. - When `$deep` is true (default): the DTO is converted to an array and any related DTOs (including arrays of DTOs) are recursively converted. - When `$deep` is false: only the top-level attributes are converted; related DTOs remain as objects. Example: ```php $users = (new UserDao($conn)) ->with(['posts']) ->findAllBy(['status' => 'active']); $deep = array_map(fn($u) => $u->toArray(), $users); // deep (default) $shallow = array_map(fn($u) => $u->toArray(false), $users); // shallow ``` ## Attribute accessors/mutators & custom casters (Milestone C) Pairity supports lightweight DTO accessors/mutators and pluggable per‑column casters declared in your DAO `schema()`. ### DTO attribute accessors/mutators - Accessor: define `protected function get{Name}Attribute($value): mixed` to transform a field when reading via property access or `toArray()`. - Mutator: define `protected function set{Name}Attribute($value): mixed` to normalize a field when the DTO is hydrated from an array (constructor/fromArray). Example: ```php class UserDto extends \Pairity\Model\AbstractDto { // Uppercase name when reading protected function getNameAttribute($value): mixed { return is_string($value) ? strtoupper($value) : $value; } // Trim name on hydration protected function setNameAttribute($value): mixed { return is_string($value) ? trim($value) : $value; } } ``` Accessors are applied for top‑level keys in `toArray(true|false)`. Relations (nested DTOs) apply their own accessors during their own `toArray()`. ### Custom casters In addition to built‑in casts (`int`, `float`, `bool`, `string`, `datetime`, `json`), you can declare a custom caster class per column. A caster implements: ```php use Pairity\Model\Casting\CasterInterface; final class MoneyCaster implements CasterInterface { public function fromStorage(mixed $value): mixed { // DB integer cents -> PHP array/object return ['cents' => (int)$value]; } public function toStorage(mixed $value): mixed { // PHP array/object -> DB integer cents return is_array($value) && isset($value['cents']) ? (int)$value['cents'] : (int)$value; } } ``` Declare it in the DAO `schema()` under the column’s `cast` using its class name: ```php protected function schema(): array { return [ 'primaryKey' => 'id', 'columns' => [ 'id' => ['cast' => 'int'], 'name' => ['cast' => 'string'], 'price_cents' => ['cast' => MoneyCaster::class], // custom caster 'meta' => ['cast' => 'json'], ], ]; } ``` Behavior: - On SELECT, Pairity hydrates the DTO and applies `fromStorage()` per column (or built‑ins). - On INSERT/UPDATE, Pairity applies `toStorage()` per column (or built‑ins) and maintains timestamp/soft‑delete behavior. - Custom caster class strings are resolved once and cached per DAO instance. See the test `tests/CastersAndAccessorsSqliteTest.php` for a complete, runnable example. ## Unit of Work (opt‑in) Pairity includes an optional Unit of Work (UoW) that can be enabled per block to batch updates/deletes and use an identity map: ```php use Pairity\Orm\UnitOfWork; UnitOfWork::run(function(UnitOfWork $uow) use ($userDao, $postDao) { $user = $userDao->findById(42); // identity map $userDao->update(42, ['name' => 'New']); // deferred update $postDao->deleteBy(['user_id' => 42]); // deferred delete }); // transactional commit ``` Behavior (MVP): - Outside a UoW, DAO calls execute immediately (today’s behavior). - Inside a UoW, updates/deletes are deferred; inserts remain immediate (to return real IDs). Commit runs within a transaction/session per connection. - Relation‑aware cascades: if a relation is marked with `cascadeDelete`, child deletes run before the parent delete at commit time. ### Optimistic locking (MVP) You can enable optimistic locking to avoid lost updates. Two strategies are supported: - SQL via DAO `schema()` ```php protected function schema(): array { return [ 'primaryKey' => 'id', 'columns' => [ 'id' => ['cast' => 'int'], 'name' => ['cast' => 'string'], 'version' => ['cast' => 'int'], ], 'locking' => [ 'type' => 'version', // or 'timestamp' 'column' => 'version', // compare‑and‑set column ], ]; } ``` When locking is enabled, `update($id, $data)` performs a compare‑and‑set on the configured column and increments it for `type = version`. If the row has changed since the read, an `OptimisticLockException` is thrown. Note: bulk `updateBy(...)` is blocked under optimistic locking to avoid unsafe mass updates. - MongoDB via DAO `locking()` ```php protected function locking(): array { return [ 'type' => 'version', 'column' => 'version' ]; } ``` Mongo `update($id, $data)` reads the current `version` and issues a conditional `updateOne` with `{$inc: {version: 1}}`. If the compare fails, an `OptimisticLockException` is thrown. Tests: see `tests/OptimisticLockSqliteTest.php` (SQLite). Mongo tests are guarded and run in CI when `ext-mongodb` and a server are available. ## Pagination Both SQL and Mongo DAOs provide pagination helpers that return DTOs alongside metadata. They honor the usual query modifiers: - SQL: `fields()`, `with([...])` (eager load) - Mongo: `fields()` (projection), `sort()`, `with([...])` Methods and return shapes: ```php // SQL /** @return array{data: array, total: int, perPage: int, currentPage: int, lastPage: int} */ $page = $userDao->paginate(page: 2, perPage: 10, criteria: ['status' => 'active']); /** @return array{data: array, perPage: int, currentPage: int, nextPage: int|null} */ $simple = $userDao->simplePaginate(page: 1, perPage: 10, criteria: []); // Mongo $page = $userMongoDao->paginate(2, 10, /* filter */ []); $simple = $userMongoDao->simplePaginate(1, 10, /* filter */ []); ``` Example (SQL + SQLite): ```php $page1 = (new UserDao($conn))->paginate(1, 10); // total + lastPage included $sp = (new UserDao($conn))->simplePaginate(1, 10); // no total; nextPage detection // With projection and eager loading $with = (new UserDao($conn)) ->fields('id','email','posts.title') ->with(['posts']) ->paginate(1, 5); ``` Example (Mongo): ```php $with = (new UserMongoDao($mongo)) ->fields('email','posts.title') ->sort(['email' => 1]) ->with(['posts']) ->paginate(1, 10, []); ``` See examples: `examples/sqlite_pagination.php` and `examples/nosql/mongo_pagination.php`. ## Query Scopes (MVP) Define small, reusable filters using scopes. Scopes are reset after each `find*`/`paginate*` call. - Ad‑hoc scope: `scope(callable $fn)` where `$fn` mutates the criteria/filter array for the next query. - Named scopes: `registerScope('name', fn (&$criteria, ...$args) => ...)` and then call `$dao->name(...$args)` before `find*`/`paginate*`. SQL example: ```php $userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; }); $active = $userDao->active()->paginate(1, 50); // Combine with ad‑hoc scope $inactive = $userDao->scope(function (&$criteria) { $criteria['status'] = 'inactive'; }) ->findAllBy(); ``` Mongo example (filter scopes): ```php $userMongoDao->registerScope('active', function (&$filter) { $filter['status'] = 'active'; }); $page = $userMongoDao->active()->paginate(1, 25, []); ``` ## Unit of Work (opt-in) Pairity offers an optional Unit of Work (UoW) that you can enable per block to batch and order mutations atomically, while keeping the familiar DAO/DTO API. What it gives you: - Identity Map: the same in-memory DTO instance per `[DAO class + id]` during the UoW scope. - Deferred mutations: inside a UoW, `update()`, `updateBy()`, `deleteById()`, and `deleteBy()` are queued and executed on commit in a transaction/session. - Atomicity: SQL paths use a transaction per connection; Mongo uses a session/transaction when supported. What stays the same: - Outside a UoW scope, DAOs behave exactly as before (immediate execution). - Inside a UoW, `insert()` executes immediately to return the real ID. Basic usage: ```php use Pairity\Orm\UnitOfWork; UnitOfWork::run(function(UnitOfWork $uow) use ($userDao, $postDao) { $user = $userDao->findById(42); // managed instance via identity map $userDao->update(42, ['name' => 'New']); // deferred $postDao->insert(['user_id' => 42, 'title' => 'Hello']); // immediate (real id) $postDao->deleteBy(['title' => 'Old']); // deferred }); // commits or rolls back on exception ``` Manual scoping: ```php $uow = UnitOfWork::begin(); // ... perform DAO calls ... $uow->commit(); // or $uow->rollback(); ``` Caveats and notes: - Inserts are immediate by design to obtain primary keys; updates/deletes are deferred. - If you need to force an immediate operation within a UoW (for advanced cases), DAOs use an internal `UnitOfWork::suspendDuring()` helper to avoid re-enqueueing nested calls. - The UoW MVP does not yet apply cascade rules; ordering is per-connection in enqueue order. ### Relation-aware delete ordering and cascades (MVP) - When you enable a UoW and enqueue a parent delete via `deleteById()`, Pairity will automatically delete child rows/documents first for relations marked with a cascade flag, then execute the parent delete. This ensures referential integrity without manual orchestration. - Supported relation types for cascades: `hasMany`, `hasOne`. - Enable cascades by adding a flag to the relation metadata in your DAO: ```php protected function relations(): array { return [ 'posts' => [ 'type' => 'hasMany', 'dao' => PostDao::class, 'foreignKey' => 'user_id', 'localKey' => 'id', 'cascadeDelete' => true, // or: 'cascade' => ['delete' => true] ], ]; } ``` Behavior details: - Inside `UnitOfWork::run(...)`, enqueuing `UserDao->deleteById($id)` will synthesize and run `PostDao->deleteBy(['user_id' => $id])` before deleting the user. - Works for both SQL DAOs and Mongo DAOs. - Current MVP focuses on delete cascades; cascades for updates and more advanced ordering rules can be added later. ## Event system (Milestone F) Pairity provides a lightweight event system so you can hook into DAO operations and UoW commits for audit logging, validation, normalization, caching hooks, etc. ### Dispatcher and subscribers - Global access: `Pairity\Events\Events::dispatcher()` returns the singleton dispatcher. - Register listeners: `listen(string $event, callable $listener, int $priority = 0)`. - Register subscribers: implement `Pairity\Events\SubscriberInterface` with `getSubscribedEvents(): array` returning `event => callable|[callable, priority]`. Example listener registration: ```php use Pairity\Events\Events; // Normalize a field before insert Events::dispatcher()->listen('dao.beforeInsert', function (array &$payload) { // Payload always contains 'dao' and table/collection + input data by reference if (($payload['table'] ?? '') === 'users') { $payload['data']['name'] = trim((string)($payload['data']['name'] ?? '')); } }); // Audit after update Events::dispatcher()->listen('dao.afterUpdate', function (array &$payload) { // $payload['dto'] is the updated DTO (SQL or Mongo) // write to your log sink here }); ``` ### Event names and payloads DAO events (SQL and Mongo): - `dao.beforeFind` → `{ dao, table|collection, criteria|filter& }` (criteria/filter is passed by reference) - `dao.afterFind` → `{ dao, table|collection, dto|null }` or `{ dao, table|collection, dtos: DTO[] }` - `dao.beforeInsert`→ `{ dao, table|collection, data& }` (data by reference) - `dao.afterInsert` → `{ dao, table|collection, dto }` - `dao.beforeUpdate`→ `{ dao, table|collection, id?, criteria?, data& }` (data by reference; `criteria` for bulk SQL updates) - `dao.afterUpdate` → `{ dao, table|collection, dto }` (or `{ affected }` for bulk SQL `updateBy`) - `dao.beforeDelete`→ `{ dao, table|collection, id? , criteria|filter?& }` - `dao.afterDelete` → `{ dao, table|collection, id?|criteria|filter?, affected:int }` Unit of Work events: - `uow.beforeCommit` → `{ context: 'uow' }` - `uow.afterCommit` → `{ context: 'uow' }` Notes: - Listeners run in priority order (higher first). Exceptions thrown inside listeners are swallowed to avoid breaking core flows. - When no listeners are registered, the overhead is negligible (fast-path checks). ### Example subscriber ```php use Pairity\Events\SubscriberInterface; use Pairity\Events\Events; final class AuditSubscriber implements SubscriberInterface { public function getSubscribedEvents(): array { return [ 'dao.afterInsert' => [[$this, 'onAfterInsert'], 10], 'uow.afterCommit' => [$this, 'onAfterCommit'], ]; } public function onAfterInsert(array &$payload): void { // e.g., push to queue/log sink } public function onAfterCommit(array &$payload): void { // flush buffered audits } } // Somewhere during bootstrap Events::dispatcher()->subscribe(new AuditSubscriber()); ``` ## Roadmap - Relations enhancements: - Nested eager loading (e.g., `posts.comments`) - `belongsToMany` (pivot tables) - Optional join‑based eager loading strategy - Unit of Work & Identity Map - Schema builder: broader ALTER coverage and more dialect nuances; better SQLite rebuild for complex constraints - CLI: additional commands and quality‑of‑life improvements - Testing matrix and examples for more drivers - Caching layer and query logging hooks - Production NoSQL adapters (MongoDB driver integration)