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; }
-`_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.
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.
-`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.
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
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
$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.
- 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.
## 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 {
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 {
- 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.
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.
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
- 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:
- 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.
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.