diff --git a/src/Auth/Models/User.php b/src/Auth/Models/User.php index 8e7f020ad..3865d0a9f 100644 --- a/src/Auth/Models/User.php +++ b/src/Auth/Models/User.php @@ -39,7 +39,7 @@ class User extends Model implements \Illuminate\Contracts\Auth\Authenticatable * @var array Relations */ public $belongsToMany = [ - 'groups' => [Group::class, 'table' => 'users_groups'] + 'groups' => [Group::class, 'table' => 'users_groups'], ]; public $belongsTo = [ @@ -154,17 +154,6 @@ public function afterLogin() $this->forceSave(); } - /** - * Delete the user groups - * @return void - */ - public function afterDelete() - { - if ($this->hasRelation('groups')) { - $this->groups()->detach(); - } - } - // // Persistence (used by Cookies and Sessions) // diff --git a/src/Database/Model.php b/src/Database/Model.php index 7e436569c..db68736c7 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -998,6 +998,7 @@ protected function performDeleteOnModel() /** * Locates relations with delete flag and cascades the delete event. + * For pivot relations, detach the pivot record unless the detach flag is false. * @return void */ protected function performDeleteOnRelations() @@ -1008,32 +1009,30 @@ protected function performDeleteOnRelations() * Hard 'delete' definition */ foreach ($relations as $name => $options) { - if (!Arr::get($options, 'delete', false)) { - continue; - } - if (!$relation = $this->{$name}) { continue; } - if ($relation instanceof EloquentModel) { - $relation->forceDelete(); - } - elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->forceDelete(); - }); - } - } - - /* - * Belongs-To-Many should clean up after itself always - */ - if ($type == 'belongsToMany') { - foreach ($relations as $name => $options) { + if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { + // we want to remove the pivot record, not the actual relation record if (Arr::get($options, 'detach', true)) { $this->{$name}()->detach(); } + } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { + // the model does not own the related record, we should not remove it. + continue; + } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { + if (!Arr::get($options, 'delete', false)) { + continue; + } + + if ($relation instanceof EloquentModel) { + $relation->forceDelete(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->forceDelete(); + }); + } } } } diff --git a/src/Database/Traits/SoftDelete.php b/src/Database/Traits/SoftDelete.php index cf19758d9..fc913b8e8 100644 --- a/src/Database/Traits/SoftDelete.php +++ b/src/Database/Traits/SoftDelete.php @@ -115,21 +115,27 @@ protected function performSoftDeleteOnRelations() $definitions = $this->getRelationDefinitions(); foreach ($definitions as $type => $relations) { foreach ($relations as $name => $options) { - if (!array_get($options, 'softDelete', false)) { - continue; - } - if (!$relation = $this->{$name}) { continue; } - - if ($relation instanceof EloquentModel) { - $relation->delete(); + if (!array_get($options, 'softDelete', false)) { + continue; } - elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->delete(); - }); + if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { + // relations using pivot table + $value = $this->fromDateTime($this->freshTimestamp()); + $this->updatePivotDeletedAtColumn($name, $options, $value); + } elseif (in_array($type, ['belongsTo', 'hasOneThrough', 'hasManyThrough', 'morphTo'])) { + // the model does not own the related record, we should not remove it. + continue; + } elseif (in_array($type, ['attachOne', 'attachMany', 'hasOne', 'hasMany', 'morphOne', 'morphMany'])) { + if ($relation instanceof EloquentModel) { + $relation->delete(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->delete(); + }); + } } } } @@ -177,6 +183,19 @@ public function restore() return $result; } + /** + * Update relation pivot table deleted_at column + */ + protected function updatePivotDeletedAtColumn(string $relationName, array $options, string|null $value) + { + // get deletedAtColumn from the relation options, otherwise use default + $deletedAtColumn = array_get($options, 'deletedAtColumn', 'deleted_at'); + + $this->{$relationName}()->newPivotQuery()->update([ + $deletedAtColumn => $value, + ]); + } + /** * Locates relations with softDelete flag and cascades the restore event. * @@ -191,18 +210,22 @@ protected function performRestoreOnRelations() continue; } - $relation = $this->{$name}()->onlyTrashed()->getResults(); - if (!$relation) { - continue; - } - - if ($relation instanceof EloquentModel) { - $relation->restore(); - } - elseif ($relation instanceof CollectionBase) { - $relation->each(function ($model) { - $model->restore(); - }); + if (in_array($type, ['belongsToMany', 'morphToMany', 'morphedByMany'])) { + // relations using pivot table + $this->updatePivotDeletedAtColumn($name, $options, null); + } else { + $relation = $this->{$name}()->onlyTrashed()->getResults(); + if (!$relation) { + continue; + } + + if ($relation instanceof EloquentModel) { + $relation->restore(); + } elseif ($relation instanceof CollectionBase) { + $relation->each(function ($model) { + $model->restore(); + }); + } } } } diff --git a/tests/Database/ModelTest.php b/tests/Database/ModelTest.php index b2a4b8b2a..276384ed8 100644 --- a/tests/Database/ModelTest.php +++ b/tests/Database/ModelTest.php @@ -1,14 +1,460 @@ createTable(); + $this->createTables(); + $this->seedTables(); + } + + protected function createTables() + { + $this->getBuilder()->create('comments', function ($table) { + $table->increments('id'); + $table->string('title'); + $table->nullableMorphs('commentable'); + }); + + $this->getBuilder()->create('imageables', function ($table) { + $table->foreignId('image_id')->nullable(); + $table->nullableMorphs('imageable'); + }); + + $this->getBuilder()->create('images', function ($table) { + $table->increments('id'); + $table->string('name'); + }); + + $this->getBuilder()->create('phones', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->foreignId('user_id')->nullable(); + }); + + $this->getBuilder()->create('posts', function ($table) { + $table->increments('id'); + $table->string('title'); + $table->foreignId('user_id')->nullable(); + }); + + $this->getBuilder()->create('role_user', function ($table) { + $table->increments('id'); + $table->foreignId('role_id')->nullable(); + $table->foreignId('user_id')->nullable(); + }); + + $this->getBuilder()->create('roles', function ($table) { + $table->increments('id'); + $table->string('name'); + }); + + $this->getBuilder()->create('tags', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->nullableMorphs('taggable'); + }); + + $this->getBuilder()->create('users', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->foreignId('website_id')->nullable(); + }); + + $this->getBuilder()->create('websites', function ($table) { + $table->increments('id'); + $table->string('url'); + }); + + $this->getBuilder()->create('test_model', function ($table) { + $table->increments('id'); + $table->string('name')->nullable(); + $table->text('data')->nullable(); + $table->text('description')->nullable(); + $table->text('meta')->nullable(); + $table->boolean('on_guard')->nullable(); + $table->timestamps(); + }); + } + + protected function seedTables() + { + $this->seeded['comments'][] = Comment::create(['title' => 'Comment1']); + $this->seeded['comments'][] = Comment::create(['title' => 'Comment2']); + + $this->seeded['images'][] = Image::create(['name' => 'Image1']); + $this->seeded['images'][] = Image::create(['name' => 'Image2']); + + $this->seeded['phones'][] = Phone::create(['name' => 'Phone1']); + $this->seeded['phones'][] = Phone::create(['name' => 'Phone2']); + + $this->seeded['roles'][] = Role::create(['name' => 'Role1']); + $this->seeded['roles'][] = Role::create(['name' => 'Role2']); + + $this->seeded['tags'][] = Tag::create(['name' => 'Tag1']); + $this->seeded['tags'][] = Tag::create(['name' => 'Tag2']); + + $this->seeded['posts'][] = Post::create(['title' => 'Post1']); + $this->seeded['posts'][0]->comments()->add($this->seeded['comments'][0]); + $this->seeded['posts'][0]->images()->attach($this->seeded['images'][0]); + $this->seeded['posts'][0]->tag()->add($this->seeded['tags'][0]); + + $this->seeded['posts'][] = Post::create(['title' => 'Post2']); + $this->seeded['posts'][1]->comments()->add($this->seeded['comments'][1]); + $this->seeded['posts'][1]->images()->attach($this->seeded['images'][1]); + $this->seeded['posts'][1]->tag()->add($this->seeded['tags'][1]); + + $this->seeded['users'][] = User::create(['name' => 'User1']); + $this->seeded['users'][0]->phone()->add($this->seeded['phones'][0]); + $this->seeded['users'][0]->posts()->add($this->seeded['posts'][0]); + $this->seeded['users'][0]->posts()->add($this->seeded['posts'][1]); + $this->seeded['users'][0]->roles()->attach($this->seeded['roles'][0]); + + $this->seeded['users'][] = User::create(['name' => 'User2']); + $this->seeded['users'][1]->phone()->add($this->seeded['phones'][1]); + $this->seeded['users'][1]->roles()->attach($this->seeded['roles'][0]); + $this->seeded['users'][1]->roles()->attach($this->seeded['roles'][1]); + + $this->seeded['websites'][] = Website::create(['url' => 'https://wintercms.com']); + $this->seeded['websites'][0]->users()->add($this->seeded['users'][0]); + + $this->seeded['websites'][] = Website::create(['url' => 'https://wintertricks.com']); + $this->seeded['websites'][1]->users()->add($this->seeded['users'][1]); + } + + // tests hasOneThrough & hasManyThrough + public function testDeleteWithThroughRelations() + { + $website = $this->seeded['websites'][0]; + $user = $this->seeded['users'][0]; + + $phoneCount = Phone::count(); + $postCount = Post::count(); + + $phoneRelationCount = $website->phone()->count(); + $postsRelationCount = $website->posts()->count(); + + $this->assertEquals($phoneRelationCount, $user->phone()->count()); + $this->assertEquals($postsRelationCount, $user->posts()->count()); + + $website->delete(); + + // verify nothing has been deleted + $this->assertEquals($phoneRelationCount, $user->phone()->count()); + $this->assertEquals($postsRelationCount, $user->posts()->count()); + + $this->assertEquals($phoneCount, Phone::count()); + $this->assertEquals($postCount, Post::count()); + } + + // tests hasMany + public function testDeleteWithHasManyRelation() + { + $website = $this->seeded['websites'][0]; + $user = $this->seeded['users'][0]; + + $websiteCount = Website::count(); + $userCount = User::count(); + + $website->delete(); + + // verify website has been deleted + $this->assertEquals($websiteCount - 1, Website::count()); + + // verify user still exists + $this->assertEquals($userCount, User::count()); + + // test with relation "delete" flag set to true + Website::extend(function ($model) { + $model->hasMany['users']['delete'] = true; + }); + + $website = Website::find($this->seeded['websites'][1]->id); + + $websiteCount = Website::count(); + $userCount = User::count(); + + $website->delete(); + + // verify website has been deleted + $this->assertEquals($websiteCount - 1, Website::count()); + + // verify user has been deleted + $this->assertEquals($userCount - 1, User::count()); + } + + // tests morphMany + public function testDeleteWithMorphManyRelation() + { + $post = $this->seeded['posts'][0]; + + $postCount = Post::count(); + $commentCount = Comment::count(); + + $post->delete(); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify comment still exists + $this->assertEquals($commentCount, Comment::count()); + + // test with relation "delete" flag set to true + Post::extend(function ($model) { + $model->morphMany['comments']['delete'] = true; + }); + + $post = Post::find($this->seeded['posts'][1]->id); + + $postCount = Post::count(); + $commentCount = Comment::count(); + + $post->delete(); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify comment has been deleted + $this->assertEquals($commentCount - 1, Comment::count()); + } + + // tests belongsToMany + public function testDeleteWithBelongsToManyRelation() + { + $user = $this->seeded['users'][0]; + + $userRolePivotCount = DB::table('role_user')->count(); + $userCount = User::count(); + $roleCount = Role::count(); + + $user->delete(); + + // verify user has been deleted + $this->assertEquals($userCount - 1, User::count()); + + // verify that pivot record has been removed + $this->assertEquals($userRolePivotCount - 1, DB::table('role_user')->count()); + + // verify both roles still exist + $this->assertEquals($roleCount, Role::count()); + + // test with relation "detach" flag set to false (default is true) + User::extend(function ($model) { + $model->belongsToMany['roles']['detach'] = false; + }); + + $user = User::find($this->seeded['users'][1]->id); + + $userRolePivotCount = DB::table('role_user')->count(); + $userCount = User::count(); + $roleCount = Role::count(); + + $user->delete(); + + // verify pivot record has NOT been removed + $this->assertEquals($userRolePivotCount, DB::table('role_user')->count()); + + // verify both roles still exist + $this->assertEquals($roleCount, Role::count()); + } + + // tests morphToMany + public function testDeleteWithMorphToManyRelation() + { + $post = $this->seeded['posts'][0]; + $image = $this->seeded['images'][0]; + + $imageablesPivotCount = DB::table('imageables')->count(); + $postCount = Post::count(); + $imageCount = Image::count(); + + $post->delete(); + + // verify that pivot record has been removed + $this->assertEquals($imageablesPivotCount - 1, DB::table('imageables')->count()); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify image still exists + $this->assertEquals($imageCount, Image::count()); + + // test with relation "detach" flag set to false (default is true) + Post::extend(function ($model) { + $model->morphToMany['images']['detach'] = false; + }); + + $post = Post::find($this->seeded['posts'][1]->id); + + $imageablesPivotCount = DB::table('imageables')->count(); + $postCount = Post::count(); + $imageCount = Image::count(); + + $post->delete(); + + // verify that pivot record has NOT been removed + $this->assertEquals($imageablesPivotCount, DB::table('imageables')->count()); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify image still exists + $this->assertEquals($imageCount, Image::count()); + } + + // tests morphedByMany + public function testDeleteWithMorphedByManyRelation() + { + $image = $this->seeded['images'][0]; + $post = $this->seeded['posts'][0]; + + $imageablesPivotCount = DB::table('imageables')->count(); + $imageCount = Image::count(); + $postCount = Post::count(); + + $image->delete(); + + // verify that pivot record has been removed + $this->assertEquals($imageablesPivotCount - 1, DB::table('imageables')->count()); + + // verify image has been deleted + $this->assertEquals($imageCount - 1, Image::count()); + + // verify post still exists + $this->assertEquals($postCount, Post::count()); + + // test with relation "detach" flag set to false (default is true) + Image::extend(function ($model) { + $model->morphedByMany['posts']['detach'] = false; + }); + + $image = Image::find($this->seeded['images'][1]->id); + + $imageablesPivotCount = DB::table('imageables')->count(); + $imageCount = Image::count(); + $postCount = Post::count(); + + $image->delete(); + + // verify that pivot record has NOT been removed + $this->assertEquals($imageablesPivotCount, DB::table('imageables')->count()); + + // verify image has been deleted + $this->assertEquals($imageCount - 1, Image::count()); + + // verify post still exists + $this->assertEquals($postCount, Post::count()); + } + + // tests hasOne + public function testDeleteWithHasOneRelation() + { + $user = $this->seeded['users'][0]; + + $userCount = User::count(); + $phoneCount = Phone::count(); + + $user->delete(); + + // verify user has been deleted + $this->assertEquals($userCount - 1, User::count()); + + // verify phone still exists + $this->assertEquals($phoneCount, Phone::count()); + + // test with relation "delete" flag set to true + User::extend(function ($model) { + $model->hasOne['phone']['delete'] = true; + }); + + $user = User::find($this->seeded['users'][1]->id); + + $userCount = User::count(); + $phoneCount = Phone::count(); + + $user->delete(); + + // verify user has been deleted + $this->assertEquals($userCount - 1, User::count()); + + // verify phone has been deleted + $this->assertEquals($phoneCount - 1, Phone::count()); + } + + // tests morphOne + public function testDeleteWithMorphOneRelation() + { + $post = $this->seeded['posts'][0]; + + $postCount = Post::count(); + $tagCount = Tag::count(); + + $post->delete(); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify tag still exists + $this->assertEquals($tagCount, Tag::count()); + + // test with relation "delete" flag set to true + Post::extend(function ($model) { + $model->morphOne['tag']['delete'] = true; + }); + + $post = Post::find($this->seeded['posts'][1]->id); + + $postCount = Post::count(); + $tagCount = Tag::count(); + + $post->delete(); + + // verify post has been deleted + $this->assertEquals($postCount - 1, Post::count()); + + // verify tag has been deleted + $this->assertEquals($tagCount - 1, Tag::count()); + } + + // tests belongsTo + public function testDeleteWithBelongsToRelation() + { + $phone = $this->seeded['phones'][0]; + $phoneCount = Phone::count(); + $userCount = User::count(); + + $phone->delete(); + + // verify phone has been deleted + $this->assertEquals($phoneCount - 1, Phone::count()); + + // verify user has NOT been deleted + $this->assertEquals($userCount, User::count()); + } + + // tests morphTo + public function testDeleteWithMorphToRelation() + { + $comment = $this->seeded['comments'][0]; + $commentCount = Comment::count(); + $postCount = Post::count(); + + $comment->delete(); + + // verify comment has been deleted + $this->assertEquals($commentCount - 1, Comment::count()); + + // verify post has NOT been deleted + $this->assertEquals($postCount, Post::count()); } public function testAddCasts() @@ -188,19 +634,6 @@ public function testUpsert() $this->assertEquals('2', $modelMiddleRow->value); } - - protected function createTable() - { - $this->getBuilder()->create('test_model', function ($table) { - $table->increments('id'); - $table->string('name')->nullable(); - $table->text('data')->nullable(); - $table->text('description')->nullable(); - $table->text('meta')->nullable(); - $table->boolean('on_guard')->nullable(); - $table->timestamps(); - }); - } } class TestModelGuarded extends Model @@ -270,3 +703,89 @@ class TestModelMiddle extends Model public $table = 'test_model_middle'; } + +class BaseModel extends Model +{ + protected static $unguarded = true; + public $timestamps = false; +} + +class Comment extends BaseModel +{ + public $morphTo = [ + 'commentable' => [] + ]; +} + +class Image extends BaseModel +{ + public $morphedByMany = [ + 'posts' => [Post::class, 'name' => 'imageable'], + ]; +} + +class Phone extends BaseModel +{ + public $belongsTo = [ + 'user' => [User::class] + ]; +} + +class Post extends BaseModel +{ + public $belongsTo = [ + 'user' => [User::class] + ]; + public $morphOne = [ + 'tag' => [Tag::class, 'name' => 'taggable'] + ]; + public $morphMany = [ + 'comments' => [Comment::class, 'name' => 'commentable'] + ]; + public $morphToMany = [ + 'images' => [Image::class, 'name' => 'imageable'] + ]; +} + +class Role extends BaseModel +{ + public $belongsToMany = [ + 'users' => [User::class] + ]; +} + +class Tag extends BaseModel +{ + public $morphTo = [ + 'taggable' => [] + ]; +} + +class User extends BaseModel +{ + public $hasOne = [ + 'phone' => [Phone::class] + ]; + public $hasMany = [ + 'posts' => [Post::class] + ]; + public $belongsTo = [ + 'website' => [Website::class] + ]; + public $belongsToMany = [ + 'roles' => [Role::class] + ]; +} + +class Website extends BaseModel +{ + public $hasMany = [ + 'users' => [User::class] + ]; + public $hasOneThrough = [ + 'phone' => [Phone::class, 'through' => User::class] + ]; + public $hasManyThrough = [ + 'posts' => [Post::class, 'through' => User::class] + ]; +} diff --git a/tests/Database/Traits/SoftDeleteTest.php b/tests/Database/Traits/SoftDeleteTest.php new file mode 100644 index 000000000..258b8208b --- /dev/null +++ b/tests/Database/Traits/SoftDeleteTest.php @@ -0,0 +1,131 @@ +seeded = [ + 'posts' => [], + 'categories' => [] + ]; + + $this->createTables(); + $this->seedTables(); + } + + protected function createTables() + { + $this->getBuilder()->create('posts', function ($table) { + $table->increments('id'); + $table->string('title')->default(''); + $table->timestamps(); + $table->timestamp('deleted_at')->nullable(); + }); + + $this->getBuilder()->create('categories', function ($table) { + $table->increments('id'); + $table->string('name'); + $table->timestamps(); + }); + + $this->getBuilder()->create('categories_posts', function ($table) { + $table->primary(['post_id', 'category_id']); + $table->unsignedInteger('post_id'); + $table->unsignedInteger('category_id'); + $table->timestamp('deleted_at')->nullable(); + }); + } + + protected function seedTables() + { + $this->seeded['posts'][] = Post::create([ + 'title' => 'First Post', + ]); + $this->seeded['posts'][] = Post::create([ + 'title' => 'Second Post', + ]); + + $this->seeded['categories'][] = Category::create([ + 'name' => 'Category 1' + ]); + $this->seeded['categories'][] = Category::create([ + 'name' => 'Category 2' + ]); + + $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][0]); + $this->seeded['posts'][0]->categories()->attach($this->seeded['categories'][1]); + + $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][0]); + $this->seeded['posts'][1]->categories()->attach($this->seeded['categories'][1]); + } + + public function testDeleteAndRestore() + { + $post = Post::first(); + $this->assertTrue($post->deleted_at === null); + $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); + + $post->delete(); + + $post = Post::withTrashed()->first(); + $this->assertTrue($post->deleted_at != null); + $this->assertTrue($post->categories()->where('deleted_at', '!=', null)->count() === 2); + $post->restore(); + + $post = Post::first(); + $this->assertTrue($post->deleted_at === null); + $this->assertTrue($post->categories()->where('deleted_at', null)->count() === 2); + } +} + +class Post extends \Winter\Storm\Database\Model +{ + use \Winter\Storm\Database\Traits\SoftDelete; + + public $table = 'posts'; + + public $fillable = ['title']; + + protected $dates = [ + 'created_at', + 'updated_at', + 'deleted_at', + ]; + + public $belongsToMany = [ + 'categories' => [ + Category::class, + 'table' => 'categories_posts', + 'key' => 'post_id', + 'otherKey' => 'category_id', + 'softDelete' => true, + ], + ]; +} + +class Category extends \Winter\Storm\Database\Model +{ + public $table = 'categories'; + + public $fillable = ['name']; + + protected $dates = [ + 'created_at', + 'updated_at', + ]; + + public $belongsToMany = [ + 'posts' => [ + Post::class, + 'table' => 'categories_posts', + 'key' => 'category_id', + 'otherKey' => 'post_id', + ], + ]; +}