37 KiB
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.2, PDO extension for your database(s)
- Install via Composer:
composer require getphred/pairity
After install, you can use the CLI at vendor/bin/pairity.
Testing
This project uses PHPUnit 10. The default test suite excludes MongoDB integration tests by default for portability.
- Install dev dependencies:
composer install
- Run the default suite (SQLite + unit tests; Mongo tests excluded by default):
vendor/bin/phpunit
- Run MongoDB integration tests (requires
ext-mongodb >= 2.1and a reachable server):- Provide connection via environment variables and include the group:
MONGO_HOST=127.0.0.1 MONGO_PORT=27017 \
vendor/bin/phpunit --group mongo-integration
Notes:
- When Mongo is unavailable or the extension is missing, Mongo tests will self‑skip.
- PostgreSQL smoke test requires environment variables (skips if missing):
POSTGRES_HOST(required to enable), optionalPOSTGRES_PORT,POSTGRES_DB,POSTGRES_USER,POSTGRES_PASS.- PostgreSQL identifiers are generated with double quotes by the schema grammar.
- MySQL tests require:
MYSQL_HOST(required to enable), optionalMYSQL_PORT,MYSQL_DB,MYSQL_USER,MYSQL_PASS.
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.
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:
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()
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()
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:
// 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.
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\SubscriberInterfacewithgetSubscribedEvents(): arrayreturningevent => callable|[callable, priority].
Example listener registration:
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;criteriafor bulk SQL updates)dao.afterUpdate→{ dao, table|collection, dto }(or{ affected }for bulk SQLupdateBy)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
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());
Performance knobs (Milestone G)
Pairity includes a few opt‑in performance features. Defaults remain conservative and portable.
-
PDO prepared‑statement cache (bounded LRU):
- Internals:
Pairity\Database\PdoConnectioncaches prepared statements by SQL string. - API:
$conn->setStatementCacheSize(100);(0 disables). Default: 100.
- Internals:
-
Query timing hook:
- API:
$conn->setQueryLogger(function(string $sql, array $params, float $ms) { /* log */ }); - Called for both
query()andexecute(); zero overhead when unset.
- API:
-
Eager loader IN‑batching (SQL + Mongo):
- DAOs chunk large
IN (...)/$inlookups to avoid huge parameter lists. - API:
$dao->setInBatchSize(1000);(default 1000) — affects internal relation fetches andfindAllWhereIn().
- DAOs chunk large
-
Metadata memoization:
- DAOs memoize
schema()andrelations()per instance to reduce repeated array building. - No user action required; available automatically.
- DAOs memoize
Example UoW + locking + snapshots demo: see examples/uow_locking_snapshot.php.
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)