| .github/workflows | ||
| bin | ||
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| composer.json | ||
| composer.lock | ||
| LICENSE | ||
| phpunit.xml.dist | ||
| README.md | ||
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.
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:
$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 viatoArray(bool $deep = true). - DAO (Data Access Object): table‑focused persistence and relations. Extend
Pairity\Model\AbstractDaoand implement:getTable(): stringdtoClass(): string(class-string of your DTO)- Optional:
schema()for casts, timestamps, soft deletes - Optional:
relations()forhasOne/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<Column>($value): ?DTOfindAllBy<Column>($value): DTO[]updateBy<Column>($value, array $data): int(returns affected rows)deleteBy<Column>($value): int
Examples:
$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:
$users = (new UserDao($conn))
->fields('id', 'name', 'posts.title')
->with(['posts'])
->findAllBy(['status' => 'active']);
Notes:
fields()affects only the nextfind*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\MongoClientConnectionviamongodb/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 dependencymongodb/mongodb(already required by this package).
Connect using the MongoConnectionManager:
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:
_idstrings that look like 24‑hex ObjectIds are automatically converted toObjectIdon input; returned documents convertObjectIdback to strings.- Aggregation pipelines are supported via
$conn->aggregate($db, $collection, $pipeline, $options). - See
examples/nosql/mongo_crud.phpfor a runnable demo.
Raw SQL
Use the ConnectionInterface behind your DAO for direct SQL.
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
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). hasOneis supported likehasManybut 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 JOINs.
Usage:
// 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 likeposts.commentsfall 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. belongsToManycontinues to use the portable two‑query pivot strategy.- Soft deletes on related tables are respected by adding
... AND related.deleted_at IS NULLto the join condition when configured in the related DAOschema(). - 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.
$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()oreagerStrategy('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=belongsToManydao= related DAO classpivot(orpivotTable) = pivot table nameforeignPivotKey= pivot column referencing the parent tablerelatedPivotKey= pivot column referencing the related tablelocalKey= parent primary key column (defaultid)relatedKey= related primary key column (defaultid)
Example (users ↔ roles):
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:
$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):
$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):
$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,updatedAtfilled automatically) - Soft deletes (update
deletedAtinstead of hard delete, with query scopes)
Example:
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(), bothcreated_atandupdated_atare auto-filled (UTCY-m-d H:i:s). - On
update()andupdateBy(),updated_atis auto-updated. - On
deleteById()/deleteBy(), if soft deletes are enabled, rows are marked by settingdeleted_atinstead of being physically removed. - Default queries exclude soft-deleted rows. Use scopes
withTrashed()andonlyTrashed()to modify visibility. - Helpers:
restoreById($id)/restoreBy($criteria)— setdeleted_atto NULL.forceDeleteById($id)/forceDeleteBy($criteria)— permanently delete.touch($id)— update only theupdated_atcolumn.
- On
Example:
$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):
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
require __DIR__.'/../vendor/autoload.php';
use Pairity\Database\ConnectionManager;
use Pairity\Migrations\Migrator;
$conn = ConnectionManager::make([
'driver' => '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
migrationstable 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 TABLEoperations; this MVP emits nativeALTER TABLEfor 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 COLUMNis unsupported, it recreates the table and copies data. Complex constraints/triggers may not be preserved.
- Pairity includes a best-effort table rebuild fallback for legacy SQLite: when
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=FILEis 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.sqlitein project root.
Migration discovery:
- The CLI looks for migrations in
./database/migrations, thenproject/database/migrations, thenexamples/migrations. - Each PHP file should
returnaMigrationInterfaceinstance (see examples). Files are applied in filename order.
Example ALTER migration (users add bio):
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:sfor portability. - Default
SELECTis*. To limit columns, usefields(); it always takes precedence.
DTO toArray (deep vs shallow)
DTOs implement toArray(bool $deep = true).
- When
$deepis true (default): the DTO is converted to an array and any related DTOs (including arrays of DTOs) are recursively converted. - When
$deepis false: only the top-level attributes are converted; related DTOs remain as objects.
Example:
$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): mixedto transform a field when reading via property access ortoArray(). - Mutator: define
protected function set{Name}Attribute($value): mixedto normalize a field when the DTO is hydrated from an array (constructor/fromArray).
Example:
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:
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:
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.
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:
// SQL
/** @return array{data: array<int, DTO>, total: int, perPage: int, currentPage: int, lastPage: int} */
$page = $userDao->paginate(page: 2, perPage: 10, criteria: ['status' => 'active']);
/** @return array{data: array<int, DTO>, 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):
$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):
$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$fnmutates the criteria/filter array for the next query. - Named scopes:
registerScope('name', fn (&$criteria, ...$args) => ...)and then call$dao->name(...$args)beforefind*/paginate*.
SQL example:
$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):
$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(), anddeleteBy()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:
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:
$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:
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(...), enqueuingUserDao->deleteById($id)will synthesize and runPostDao->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.
Roadmap
- Relations enhancements:
- Nested eager loading (e.g.,
posts.comments) belongsToMany(pivot tables)- Optional join‑based eager loading strategy
- Nested eager loading (e.g.,
- 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)