add Unit of Work (opt‑in) docs and guarded Mongo UoW test

This commit is contained in:
Funky Waddle 2025-12-10 08:43:00 -06:00
parent 2c3b219d9c
commit 5e007a72dd
2 changed files with 162 additions and 0 deletions

View file

@ -496,6 +496,45 @@ $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()`, 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
$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:
```php
$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:

View file

@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Pairity\Tests;
use PHPUnit\Framework\TestCase;
use Pairity\NoSql\Mongo\MongoConnectionManager;
use Pairity\NoSql\Mongo\AbstractMongoDao;
use Pairity\Orm\UnitOfWork;
use Pairity\Model\AbstractDto;
final class UnitOfWorkMongoTest extends TestCase
{
private function hasMongoExt(): bool
{
return \extension_loaded('mongodb');
}
public function testDeferredUpdateAndDeleteCommit(): void
{
if (!$this->hasMongoExt()) {
$this->markTestSkipped('ext-mongodb not loaded');
}
// Connect (skip if server unavailable)
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
// Inline DTO and DAO
$dto = new class([]) extends AbstractDto {};
$dtoClass = \get_class($dto);
$dao = new class($conn, $dtoClass) extends AbstractMongoDao {
private string $dto;
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.uow_docs'; }
protected function dtoClass(): string { return $this->dto; }
};
// Clean collection
foreach ($dao->findAllBy([]) as $doc) {
$id = (string)($doc->toArray(false)['_id'] ?? '');
if ($id !== '') { $dao->deleteById($id); }
}
// Insert a document (immediate)
$created = $dao->insert(['name' => 'Widget', 'qty' => 1]);
$id = (string)($created->toArray(false)['_id'] ?? '');
$this->assertNotEmpty($id);
// Run UoW with deferred update then delete
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
$one = $dao->findById($id);
$this->assertNotNull($one);
// defer update
$dao->update($id, ['qty' => 2]);
// defer delete
$dao->deleteById($id);
// commit at end of run()
});
// After commit, it should be deleted
$this->assertNull($dao->findById($id));
}
public function testRollbackOnExceptionClearsOps(): void
{
if (!$this->hasMongoExt()) {
$this->markTestSkipped('ext-mongodb not loaded');
}
try {
$conn = MongoConnectionManager::make([
'host' => \getenv('MONGO_HOST') ?: '127.0.0.1',
'port' => (int)(\getenv('MONGO_PORT') ?: 27017),
]);
} catch (\Throwable $e) {
$this->markTestSkipped('Mongo not available: ' . $e->getMessage());
}
$dto = new class([]) extends AbstractDto {};
$dtoClass = \get_class($dto);
$dao = new class($conn, $dtoClass) extends AbstractMongoDao {
private string $dto;
public function __construct($c, string $dto) { parent::__construct($c); $this->dto = $dto; }
protected function collection(): string { return 'pairity_test.uow_docs'; }
protected function dtoClass(): string { return $this->dto; }
};
// Clean
foreach ($dao->findAllBy([]) as $doc) {
$id = (string)($doc->toArray(false)['_id'] ?? '');
if ($id !== '') { $dao->deleteById($id); }
}
// Insert and capture id
$created = $dao->insert(['name' => 'Widget', 'qty' => 1]);
$id = (string)($created->toArray(false)['_id'] ?? '');
// Attempt a UoW that throws
try {
UnitOfWork::run(function(UnitOfWork $uow) use ($dao, $id) {
$dao->update($id, ['qty' => 99]);
throw new \RuntimeException('boom');
});
$this->fail('Exception expected');
} catch (\RuntimeException $e) {
// expected
}
// Update should not have been applied due to rollback
$after = $dao->findById($id);
$this->assertSame(1, $after?->toArray(false)['qty'] ?? null);
}
}