From 5e007a72dd957745fc90af297e90c34a6191255d Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Wed, 10 Dec 2025 08:43:00 -0600 Subject: [PATCH] =?UTF-8?q?add=20Unit=20of=20Work=20(opt=E2=80=91in)=20doc?= =?UTF-8?q?s=20and=20guarded=20Mongo=20UoW=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 39 +++++++++++ tests/UnitOfWorkMongoTest.php | 123 ++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/UnitOfWorkMongoTest.php diff --git a/README.md b/README.md index 0ad7e07..2726302 100644 --- a/README.md +++ b/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: diff --git a/tests/UnitOfWorkMongoTest.php b/tests/UnitOfWorkMongoTest.php new file mode 100644 index 0000000..4860685 --- /dev/null +++ b/tests/UnitOfWorkMongoTest.php @@ -0,0 +1,123 @@ +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); + } +}