add Unit of Work (opt‑in) docs and guarded Mongo UoW test
This commit is contained in:
parent
2c3b219d9c
commit
5e007a72dd
39
README.md
39
README.md
|
|
@ -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:
|
||||
|
|
|
|||
123
tests/UnitOfWorkMongoTest.php
Normal file
123
tests/UnitOfWorkMongoTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue