A partitioned model ORM. Handles DAO and DTO objects
Go to file
Funky Waddle a7fc289ebe
Some checks are pending
CI / test (8.1) (push) Waiting to run
CI / test (8.2) (push) Waiting to run
CI / test (8.3) (push) Waiting to run
update required version of mongodb/mongodb
2025-12-11 08:21:44 -06:00
.github/workflows Docs/examples/CI polish 2025-12-11 07:37:40 -06:00
bin Mongo updates 2025-12-10 08:02:24 -06:00
examples Docs/examples/CI polish 2025-12-11 07:37:40 -06:00
src Docs/examples/CI polish 2025-12-11 07:37:40 -06:00
tests Docs/examples/CI polish 2025-12-11 07:37:40 -06:00
.gitignore Initial commit 2025-12-10 07:01:07 -06:00
.phpunit.result.cache finish Unit of Work enhancements. Add Event handling 2025-12-11 00:01:53 -06:00
CHANGELOG.md Docs/examples/CI polish 2025-12-11 07:37:40 -06:00
composer.json update required version of mongodb/mongodb 2025-12-11 08:21:44 -06:00
composer.lock join‑based eager loading (SQL) 2025-12-10 21:09:04 -06:00
LICENSE Initial commit 2025-12-09 22:00:02 +00:00
phpunit.xml.dist Initial commit 2025-12-10 07:01:07 -06:00
README.md Update README and composer.json files in preparation for submission to Packagist 2025-12-11 07:52:08 -06:00

Pairity

A partitionedmodel PHP ORM (DTO/DAO) with Query Builder, relations, raw SQL helpers, and a portable migrations + schema builder. Namespace: Pairity\. Package: getphred/pairity.

CI Packagist

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 via toArray(bool $deep = true).
  • DAO (Data Access Object): tablefocused 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 DAOcentric: call with([...]) to eager load; load()/loadMany() for lazy.
  • Field projection via fields('id', 'name', 'posts.title') with dotnotation for related selects.
  • Raw SQL: use ConnectionInterface::query, execute, transaction, lastInsertId.
  • Query Builder: a simple builder (Pairity\Query\QueryBuilder) exists for adhoc SQL composition.

Dynamic DAO methods

AbstractDao supports dynamic helpers, mapped to column names (Studly/camel to snake_case):

  • findOneBy<Column>($value): ?DTO
  • findAllBy<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 dotnotation:
$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 (inmemory) remains for experimentation without external deps.

MongoDB (production adapter)

Pairity includes a productionready 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:

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 24hex 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.

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 (DAOcentric 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).
  • hasOne is supported like hasMany but attaches a single DTO instead of a list.

Joinbased eager loading (optin, SQL)

For singlelevel relations on SQL DAOs, you can optin to a joinbased 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()           // optin for the next call
    ->with(['posts'])          // singlelevel only in MVP
    ->findAllBy([]);

Behavior and limitations (MVP):

  • Singlelevel 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 twoquery 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().
  • Perrelation constraints that rely on ordering/limits arent applied in join mode in this MVP; prefer the default batched strategy for those cases.

Tip: If join mode cant be used (e.g., nested paths or missing relation field projections), Pairity silently falls back to the portable batched eager loader.

Perrelation hint (optional):

You can hint join strategy per relation path. This is useful when you want to selectively join specific relations in a singlelevel eager load. The join will be used only when safe (singlelevel 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() or eagerStrategy('join'), that global setting takes precedence.
  • Join eager is still limited to singlelevel relations in this MVP. Nested paths (e.g., posts.comments) will use the portable strategy.

belongsToMany (SQL) and pivot helpers

Pairity supports manytomany relations for SQL DAOs via a pivot table. Declare belongsToMany in your DAOs relations() and use the builtin 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):

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 users posts and each posts 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.

Perrelation 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 (perrelation 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, updatedAt filled automatically)
  • Soft deletes (update deletedAt instead 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(), 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:

$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 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):

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:

$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 percolumn 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:

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 toplevel keys in toArray(true|false). Relations (nested DTOs) apply their own accessors during their own toArray().

Custom casters

In addition to builtin 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 columns 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 builtins).
  • On INSERT/UPDATE, Pairity applies toStorage() per column (or builtins) and maintains timestamp/softdelete 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 (optin)

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 (todays behavior).
  • Inside a UoW, updates/deletes are deferred; inserts remain immediate (to return real IDs). Commit runs within a transaction/session per connection.
  • Relationaware 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',   // compareandset column
        ],
    ];
}

When locking is enabled, update($id, $data) performs a compareandset 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.

  • Adhoc 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:

$userDao->registerScope('active', function (&$criteria) { $criteria['status'] = 'active'; });

$active = $userDao->active()->paginate(1, 50);

// Combine with adhoc 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(), 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:

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(...), 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:

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

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 optin performance features. Defaults remain conservative and portable.

  • PDO preparedstatement cache (bounded LRU):

    • Internals: Pairity\Database\PdoConnection caches prepared statements by SQL string.
    • API: $conn->setStatementCacheSize(100); (0 disables). Default: 100.
  • Query timing hook:

    • API: $conn->setQueryLogger(function(string $sql, array $params, float $ms) { /* log */ });
    • Called for both query() and execute(); zero overhead when unset.
  • Eager loader INbatching (SQL + Mongo):

    • DAOs chunk large IN (...) / $in lookups to avoid huge parameter lists.
    • API: $dao->setInBatchSize(1000); (default 1000) — affects internal relation fetches and findAllWhereIn().
  • Metadata memoization:

    • DAOs memoize schema() and relations() per instance to reduce repeated array building.
    • No user action required; available automatically.

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 joinbased 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 qualityoflife improvements
  • Testing matrix and examples for more drivers
  • Caching layer and query logging hooks
  • Production NoSQL adapters (MongoDB driver integration)