diff --git a/README.md b/README.md index 2726302..150cf53 100644 --- a/README.md +++ b/README.md @@ -535,6 +535,34 @@ Caveats and notes: - 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. +### Relation-aware delete ordering and cascades (MVP) + +- When you enable a UoW and enqueue a parent delete via `deleteById()`, Pairity will automatically delete child rows/documents first for relations marked with a cascade flag, then execute the parent delete. This ensures referential integrity without manual orchestration. + +- Supported relation types for cascades: `hasMany`, `hasOne`. + +- Enable cascades by adding a flag to the relation metadata in your DAO: + +```php +protected function relations(): array +{ + return [ + 'posts' => [ + 'type' => 'hasMany', + 'dao' => PostDao::class, + 'foreignKey' => 'user_id', + 'localKey' => 'id', + 'cascadeDelete' => true, // or: 'cascade' => ['delete' => true] + ], + ]; +} +``` + +Behavior details: +- Inside `UnitOfWork::run(...)`, enqueuing `UserDao->deleteById($id)` will synthesize and run `PostDao->deleteBy(['user_id' => $id])` before deleting the user. +- Works for both SQL DAOs and Mongo DAOs. +- Current MVP focuses on delete cascades; cascades for updates and more advanced ordering rules can be added later. + ## Roadmap - Relations enhancements: diff --git a/tests/UnitOfWorkCascadeMongoTest.php b/tests/UnitOfWorkCascadeMongoTest.php new file mode 100644 index 0000000..ee1ca25 --- /dev/null +++ b/tests/UnitOfWorkCascadeMongoTest.php @@ -0,0 +1,93 @@ +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 DTOs + $userDto = new class([]) extends AbstractDto {}; + $userDtoClass = \get_class($userDto); + $postDto = new class([]) extends AbstractDto {}; + $postDtoClass = \get_class($postDto); + + // Inline DAOs with relation and cascadeDelete=true + $UserDao = new class($conn, $userDtoClass, $postDtoClass) extends AbstractMongoDao { + private string $userDto; private string $postDto; public function __construct($c, string $u, string $p) { parent::__construct($c); $this->userDto = $u; $this->postDto = $p; } + protected function collection(): string { return 'pairity_test.uow_users_cascade'; } + protected function dtoClass(): string { return $this->userDto; } + protected function relations(): array { + return [ + 'posts' => [ + 'type' => 'hasMany', + 'dao' => get_class(new class($this->getConnection(), $this->postDto) extends AbstractMongoDao { + private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; } + protected function collection(): string { return 'pairity_test.uow_posts_cascade'; } + protected function dtoClass(): string { return $this->dto; } + }), + 'foreignKey' => 'user_id', + 'localKey' => '_id', + 'cascadeDelete' => true, + ], + ]; + } + }; + + $PostDao = new class($conn, $postDtoClass) extends AbstractMongoDao { + private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; } + protected function collection(): string { return 'pairity_test.uow_posts_cascade'; } + protected function dtoClass(): string { return $this->dto; } + }; + + $userDao = new $UserDao($conn, $userDtoClass, $postDtoClass); + $postDao = new $PostDao($conn, $postDtoClass); + + // Clean + foreach ($postDao->findAllBy([]) as $p) { $id = (string)($p->toArray(false)['_id'] ?? ''); if ($id) { $postDao->deleteById($id); } } + foreach ($userDao->findAllBy([]) as $u) { $id = (string)($u->toArray(false)['_id'] ?? ''); if ($id) { $userDao->deleteById($id); } } + + // Seed + $u = $userDao->insert(['email' => 'c@example.com']); + $uid = (string)($u->toArray(false)['_id'] ?? ''); + $postDao->insert(['user_id' => $uid, 'title' => 'A']); + $postDao->insert(['user_id' => $uid, 'title' => 'B']); + + // UoW: delete parent -> children should be deleted first + UnitOfWork::run(function() use ($userDao, $uid) { + $userDao->deleteById($uid); + }); + + // Verify + $children = $postDao->findAllBy(['user_id' => $uid]); + $this->assertCount(0, $children, 'Child posts should be deleted via cascade'); + $this->assertNull($userDao->findById($uid)); + } +} diff --git a/tests/UnitOfWorkCascadeSqliteTest.php b/tests/UnitOfWorkCascadeSqliteTest.php new file mode 100644 index 0000000..37403f8 --- /dev/null +++ b/tests/UnitOfWorkCascadeSqliteTest.php @@ -0,0 +1,83 @@ + 'sqlite', 'path' => ':memory:']); + } + + public function testDeleteByIdCascadesToChildren(): void + { + $conn = $this->conn(); + // schema + $conn->execute('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT)'); + $conn->execute('CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT)'); + + // DTOs + $userDto = new class([]) extends AbstractDto {}; + $postDto = new class([]) extends AbstractDto {}; + $userDtoClass = get_class($userDto); + $postDtoClass = get_class($postDto); + + // DAOs with hasMany relation and cascadeDelete=true + $UserDao = new class($conn, $userDtoClass, $postDtoClass) extends AbstractDao { + private string $userDto; private string $postDto; + public function __construct($c, string $u, string $p) { parent::__construct($c); $this->userDto = $u; $this->postDto = $p; } + public function getTable(): string { return 'users'; } + protected function dtoClass(): string { return $this->userDto; } + protected function relations(): array { + return [ + 'posts' => [ + 'type' => 'hasMany', + 'dao' => get_class(new class($this->getConnection(), $this->postDto) extends AbstractDao { + private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; } + public function getTable(): string { return 'posts'; } + protected function dtoClass(): string { return $this->dto; } + }), + 'foreignKey' => 'user_id', + 'localKey' => 'id', + 'cascadeDelete' => true, + ], + ]; + } + protected function schema(): array { return ['primaryKey' => 'id', 'columns' => ['id'=>['cast'=>'int'],'email'=>['cast'=>'string']]]; } + }; + + $PostDao = new class($conn, $postDtoClass) extends AbstractDao { + private string $dto; public function __construct($c, string $d) { parent::__construct($c); $this->dto = $d; } + public function getTable(): string { return 'posts'; } + protected function dtoClass(): string { return $this->dto; } + protected function schema(): array { return ['primaryKey'=>'id','columns'=>['id'=>['cast'=>'int'],'user_id'=>['cast'=>'int'],'title'=>['cast'=>'string']]]; } + }; + + $userDao = new $UserDao($conn, $userDtoClass, $postDtoClass); + $postDao = new $PostDao($conn, $postDtoClass); + + // seed + $u = $userDao->insert(['email' => 'c@example.com']); + $uid = (int)($u->toArray(false)['id'] ?? 0); + $postDao->insert(['user_id' => $uid, 'title' => 'A']); + $postDao->insert(['user_id' => $uid, 'title' => 'B']); + + // UoW: delete user; expect posts to be deleted first via cascade + UnitOfWork::run(function() use ($userDao, $uid) { + $userDao->deleteById($uid); + }); + + // verify posts gone and user gone + $remainingPosts = $postDao->findAllBy(['user_id' => $uid]); + $this->assertCount(0, $remainingPosts, 'Child posts should be deleted via cascade'); + $this->assertNull($userDao->findById($uid)); + } +}