| .github/workflows | ||
| bin | ||
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| composer.json | ||
| 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.
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
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.
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)