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
|
$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
|
## Roadmap
|
||||||
|
|
||||||
- Relations enhancements:
|
- 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