From e0f59c4ab09dd734ac80ed2872e5ce8a430b0360 Mon Sep 17 00:00:00 2001 From: Chris Brown Date: Wed, 12 Jul 2023 20:50:24 -0400 Subject: [PATCH] Add withoutRole and withoutPermission scopes This allows the inverse of the prior scopes to now allow finding users that do-not-have the specified role or permission Ref: #1037 --- docs/basic-usage/basic-usage.md | 10 ++- src/Traits/HasPermissions.php | 31 +++++++ src/Traits/HasRoles.php | 30 +++++++ tests/HasPermissionsTest.php | 61 ++++++++++++-- tests/HasRolesTest.php | 138 ++++++++++++++++++++++++++++++++ tests/TeamHasRolesTest.php | 4 + 6 files changed, 264 insertions(+), 10 deletions(-) diff --git a/docs/basic-usage/basic-usage.md b/docs/basic-usage/basic-usage.md index 56bdd04d8..ea0d9e172 100644 --- a/docs/basic-usage/basic-usage.md +++ b/docs/basic-usage/basic-usage.md @@ -75,18 +75,20 @@ $roles = $user->getRoleNames(); // Returns a collection ``` ## Scopes -The `HasRoles` trait also adds a `role` scope to your models to scope the query to certain roles or permissions: +The `HasRoles` trait also adds `role` and `withoutRole` scopes to your models to scope the query to certain roles or permissions: ```php $users = User::role('writer')->get(); // Returns only users with the role 'writer' +$nonEditors = User::withoutRole('editor')->get(); // Returns only users without the role 'editor' ``` -The `role` scope can accept a string, a `\Spatie\Permission\Models\Role` object or an `\Illuminate\Support\Collection` object. +The `role` and `withoutRole` scopes can accept a string, a `\Spatie\Permission\Models\Role` object or an `\Illuminate\Support\Collection` object. -The same trait also adds a scope to only get users that have a certain permission. +The same trait also adds scopes to only get users that have or don't have a certain permission. ```php $users = User::permission('edit articles')->get(); // Returns only users with the permission 'edit articles' (inherited or directly) +$usersWhoCannotEditArticles = User::withoutPermission('edit articles')->get(); // Returns all users without the permission 'edit articles' (inherited or directly) ``` The scope can accept a string, a `\Spatie\Permission\Models\Permission` object or an `\Illuminate\Support\Collection` object. @@ -97,7 +99,7 @@ Since Role and Permission models are extended from Eloquent models, basic Eloque ```php $all_users_with_all_their_roles = User::with('roles')->get(); -$all_users_with_all_direct_permissions = User::with('permissions')->get(); +$all_users_with_all_their_direct_permissions = User::with('permissions')->get(); $all_roles_in_database = Role::all()->pluck('name'); $users_without_any_roles = User::doesntHave('roles')->get(); $all_roles_except_a_and_b = Role::whereNotIn('name', ['role A', 'role B'])->get(); diff --git a/src/Traits/HasPermissions.php b/src/Traits/HasPermissions.php index 6b6c8c91e..1404bb501 100644 --- a/src/Traits/HasPermissions.php +++ b/src/Traits/HasPermissions.php @@ -122,6 +122,37 @@ public function scopePermission(Builder $query, $permissions): Builder ); } + /** + * Scope the model query to only those without certain permissions, + * whether indirectly by role or by direct permission. + * + * @param string|int|array|Permission|Collection|\BackedEnum $permissions + */ + public function scopeWithoutPermission(Builder $query, $permissions, $debug = false): Builder + { + $permissions = $this->convertToPermissionModels($permissions); + + $permissionClass = $this->getPermissionClass(); + $permissionKey = (new $permissionClass())->getKeyName(); + $roleClass = is_a($this, Role::class) ? static::class : $this->getRoleClass(); + $roleKey = (new $roleClass())->getKeyName(); + + $rolesWithPermissions = is_a($this, Role::class) ? [] : array_unique( + array_reduce($permissions, fn ($result, $permission) => array_merge($result, $permission->roles->all()), []) + ); + + return $query->where(fn (Builder $query) => $query + ->whereDoesntHave('permissions', fn (Builder $subQuery) => $subQuery + ->whereIn(config('permission.table_names.permissions').".$permissionKey", \array_column($permissions, $permissionKey)) + ) + ->when(count($rolesWithPermissions), fn ($whenQuery) => $whenQuery + ->whereDoesntHave('roles', fn (Builder $subQuery) => $subQuery + ->whereIn(config('permission.table_names.roles').".$roleKey", \array_column($rolesWithPermissions, $roleKey)) + ) + ) + ); + } + /** * @param string|int|array|Permission|Collection|\BackedEnum $permissions * diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index ab637a795..8e9664cd7 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -95,6 +95,36 @@ public function scopeRole(Builder $query, $roles, $guard = null): Builder ); } + /** + * Scope the model query to only those without certain roles. + * + * @param string|int|array|Role|Collection $roles + * @param string $guard + */ + public function scopeWithoutRole(Builder $query, $roles, $guard = null): Builder + { + if ($roles instanceof Collection) { + $roles = $roles->all(); + } + + $roles = array_map(function ($role) use ($guard) { + if ($role instanceof Role) { + return $role; + } + + $method = is_int($role) || PermissionRegistrar::isUid($role) ? 'findById' : 'findByName'; + + return $this->getRoleClass()::{$method}($role, $guard ?: $this->getDefaultGuardName()); + }, Arr::wrap($roles)); + + $roleClass = $this->getRoleClass(); + $key = (new $roleClass())->getKeyName(); + + return $query->whereHas('roles', fn (Builder $subQuery) => $subQuery + ->whereNotIn(config('permission.table_names.roles').".$key", \array_column($roles, $key)) + ); + } + /** * Returns roles ids as array keys * diff --git a/tests/HasPermissionsTest.php b/tests/HasPermissionsTest.php index e5c9a4c85..fc2a10270 100644 --- a/tests/HasPermissionsTest.php +++ b/tests/HasPermissionsTest.php @@ -87,123 +87,160 @@ public function it_can_scope_users_using_enums() $enum2 = TestModels\TestRolePermissionsEnum::EDITARTICLES; $permission1 = app(Permission::class)->findOrCreate($enum1->value, 'web'); $permission2 = app(Permission::class)->findOrCreate($enum2->value, 'web'); + + User::all()->each(fn($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $user1->givePermissionTo([$enum1, $enum2]); $this->testUserRole->givePermissionTo($enum2); $user2->assignRole('testRole'); $scopedUsers1 = User::permission($enum2)->get(); $scopedUsers2 = User::permission([$enum1])->get(); + $scopedUsers3 = User::withoutPermission([$enum1])->get(); + $scopedUsers4 = User::withoutPermission([$enum2])->get(); $this->assertEquals(2, $scopedUsers1->count()); $this->assertEquals(1, $scopedUsers2->count()); + $this->assertEquals(2, $scopedUsers3->count()); + $this->assertEquals(1, $scopedUsers4->count()); } /** @test */ public function it_can_scope_users_using_a_string() { + User::all()->each(fn($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $user1->givePermissionTo(['edit-articles', 'edit-news']); $this->testUserRole->givePermissionTo('edit-articles'); $user2->assignRole('testRole'); $scopedUsers1 = User::permission('edit-articles')->get(); $scopedUsers2 = User::permission(['edit-news'])->get(); + $scopedUsers3 = User::withoutPermission('edit-news')->get(); $this->assertEquals(2, $scopedUsers1->count()); $this->assertEquals(1, $scopedUsers2->count()); + $this->assertEquals(2, $scopedUsers3->count()); } /** @test */ public function it_can_scope_users_using_a_int() { + User::all()->each(fn($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $user1->givePermissionTo([1, 2]); $this->testUserRole->givePermissionTo(1); $user2->assignRole('testRole'); $scopedUsers1 = User::permission(1)->get(); $scopedUsers2 = User::permission([2])->get(); + $scopedUsers3 = User::withoutPermission([2])->get(); $this->assertEquals(2, $scopedUsers1->count()); $this->assertEquals(1, $scopedUsers2->count()); + $this->assertEquals(2, $scopedUsers3->count()); } /** @test */ public function it_can_scope_users_using_an_array() { + User::all()->each(fn($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $user1->givePermissionTo(['edit-articles', 'edit-news']); $this->testUserRole->givePermissionTo('edit-articles'); $user2->assignRole('testRole'); + $user3->assignRole('testRole2'); $scopedUsers1 = User::permission(['edit-articles', 'edit-news'])->get(); $scopedUsers2 = User::permission(['edit-news'])->get(); + $scopedUsers3 = User::withoutPermission(['edit-news'])->get(); $this->assertEquals(2, $scopedUsers1->count()); $this->assertEquals(1, $scopedUsers2->count()); + $this->assertEquals(2, $scopedUsers3->count()); } /** @test */ public function it_can_scope_users_using_a_collection() { + User::all()->each(fn($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $user1->givePermissionTo(['edit-articles', 'edit-news']); $this->testUserRole->givePermissionTo('edit-articles'); $user2->assignRole('testRole'); + $user3->assignRole('testRole2'); $scopedUsers1 = User::permission(collect(['edit-articles', 'edit-news']))->get(); $scopedUsers2 = User::permission(collect(['edit-news']))->get(); + $scopedUsers3 = User::withoutPermission(collect(['edit-news']))->get(); $this->assertEquals(2, $scopedUsers1->count()); $this->assertEquals(1, $scopedUsers2->count()); + $this->assertEquals(2, $scopedUsers3->count()); } /** @test */ public function it_can_scope_users_using_an_object() { + User::all()->each(fn($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user1->givePermissionTo($this->testUserPermission->name); $scopedUsers1 = User::permission($this->testUserPermission)->get(); $scopedUsers2 = User::permission([$this->testUserPermission])->get(); $scopedUsers3 = User::permission(collect([$this->testUserPermission]))->get(); + $scopedUsers4 = User::withoutPermission(collect([$this->testUserPermission]))->get(); $this->assertEquals(1, $scopedUsers1->count()); $this->assertEquals(1, $scopedUsers2->count()); $this->assertEquals(1, $scopedUsers3->count()); + $this->assertEquals(0, $scopedUsers4->count()); } /** @test */ - public function it_can_scope_users_without_permissions_only_role() + public function it_can_scope_users_without_direct_permissions_only_role() { + User::all()->each(fn($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $this->testUserRole->givePermissionTo('edit-articles'); $user1->assignRole('testRole'); $user2->assignRole('testRole'); + $user3->assignRole('testRole2'); - $scopedUsers = User::permission('edit-articles')->get(); + $scopedUsers1 = User::permission('edit-articles')->get(); + $scopedUsers2 = User::withoutPermission('edit-articles')->get(); - $this->assertEquals(2, $scopedUsers->count()); + $this->assertEquals(2, $scopedUsers1->count()); + $this->assertEquals(1, $scopedUsers2->count()); } /** @test */ - public function it_can_scope_users_without_permissions_only_permission() + public function it_can_scope_users_with_only_direct_permission() { + User::all()->each(fn($item) => $item->delete()); $user1 = User::create(['email' => 'user1@test.com']); $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); $user1->givePermissionTo(['edit-news']); $user2->givePermissionTo(['edit-articles', 'edit-news']); - $scopedUsers = User::permission('edit-news')->get(); + $scopedUsers1 = User::permission('edit-news')->get(); + $scopedUsers2 = User::withoutPermission('edit-news')->get(); - $this->assertEquals(2, $scopedUsers->count()); + $this->assertEquals(2, $scopedUsers1->count()); + $this->assertEquals(1, $scopedUsers2->count()); } /** @test */ @@ -252,6 +289,10 @@ public function it_throws_an_exception_when_trying_to_scope_a_non_existing_permi $this->expectException(PermissionDoesNotExist::class); User::permission('not defined permission')->get(); + + $this->expectException(PermissionDoesNotExist::class); + + User::withoutPermission('not defined permission')->get(); } /** @test */ @@ -261,9 +302,17 @@ public function it_throws_an_exception_when_trying_to_scope_a_permission_from_an User::permission('testAdminPermission')->get(); + $this->expectException(PermissionDoesNotExist::class); + + User::withoutPermission('testAdminPermission')->get(); + $this->expectException(GuardDoesNotMatch::class); User::permission($this->testAdminPermission)->get(); + + $this->expectException(GuardDoesNotMatch::class); + + User::withoutPermission($this->testAdminPermission)->get(); } /** @test */ diff --git a/tests/HasRolesTest.php b/tests/HasRolesTest.php index 8dafa0e7b..870177fd6 100644 --- a/tests/HasRolesTest.php +++ b/tests/HasRolesTest.php @@ -386,6 +386,21 @@ public function it_can_scope_users_using_a_string() $this->assertEquals(1, $scopedUsers->count()); } + /** @test */ + public function it_can_withoutscope_users_using_a_string() + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole('testRole'); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $scopedUsers = User::withoutRole('testRole2')->get(); + + $this->assertEquals(1, $scopedUsers->count()); + } + /** @test */ public function it_can_scope_users_using_an_array() { @@ -401,6 +416,23 @@ public function it_can_scope_users_using_an_array() $this->assertEquals(2, $scopedUsers2->count()); } + /** @test */ + public function it_can_withoutscope_users_using_an_array() + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $scopedUsers1 = User::withoutRole([$this->testUserRole])->get(); + $scopedUsers2 = User::withoutRole([$this->testUserRole->name, 'testRole2'])->get(); + + $this->assertEquals(2, $scopedUsers1->count()); + $this->assertEquals(0, $scopedUsers2->count()); + } + /** @test */ public function it_can_scope_users_using_an_array_of_ids_and_names() { @@ -417,6 +449,26 @@ public function it_can_scope_users_using_an_array_of_ids_and_names() $this->assertEquals(2, $scopedUsers->count()); } + /** @test */ + public function it_can_withoutscope_users_using_an_array_of_ids_and_names() + { + app(Role::class)->create(['name' => 'testRole3']); + + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $firstAssignedRoleName = $this->testUserRole->name; + $unassignedRoleId = app(Role::class)->findByName('testRole3')->getKey(); + + $scopedUsers = User::withoutRole([$firstAssignedRoleName, $unassignedRoleId])->get(); + + $this->assertEquals(2, $scopedUsers->count()); + } + /** @test */ public function it_can_scope_users_using_a_collection() { @@ -432,6 +484,25 @@ public function it_can_scope_users_using_a_collection() $this->assertEquals(2, $scopedUsers2->count()); } + /** @test */ + public function it_can_withoutscope_users_using_a_collection() + { + app(Role::class)->create(['name' => 'testRole3']); + + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole'); + $user3->assignRole('testRole2'); + + $scopedUsers1 = User::withoutRole([$this->testUserRole])->get(); + $scopedUsers2 = User::withoutRole(collect(['testRole', 'testRole3']))->get(); + + $this->assertEquals(1, $scopedUsers1->count()); + $this->assertEquals(1, $scopedUsers2->count()); + } + /** @test */ public function it_can_scope_users_using_an_object() { @@ -449,6 +520,25 @@ public function it_can_scope_users_using_an_object() $this->assertEquals(1, $scopedUsers3->count()); } + /** @test */ + public function it_can_withoutscope_users_using_an_object() + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole($this->testUserRole); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $scopedUsers1 = User::withoutRole($this->testUserRole)->get(); + $scopedUsers2 = User::withoutRole([$this->testUserRole])->get(); + $scopedUsers3 = User::withoutRole(collect([$this->testUserRole]))->get(); + + $this->assertEquals(2, $scopedUsers1->count()); + $this->assertEquals(2, $scopedUsers2->count()); + $this->assertEquals(2, $scopedUsers3->count()); + } + /** @test */ public function it_can_scope_against_a_specific_guard() { @@ -475,6 +565,34 @@ public function it_can_scope_against_a_specific_guard() $this->assertEquals(1, $scopedUsers3->count()); } + /** @test */ + public function it_can_withoutscope_against_a_specific_guard() + { + $user1 = User::create(['email' => 'user1@test.com']); + $user2 = User::create(['email' => 'user2@test.com']); + $user3 = User::create(['email' => 'user3@test.com']); + $user1->assignRole('testRole'); + $user2->assignRole('testRole2'); + $user3->assignRole('testRole2'); + + $scopedUsers1 = User::withoutRole('testRole', 'web')->get(); + + $this->assertEquals(2, $scopedUsers1->count()); + + $user4 = Admin::create(['email' => 'user4@test.com']); + $user5 = Admin::create(['email' => 'user5@test.com']); + $user6 = Admin::create(['email' => 'user6@test.com']); + $testAdminRole2 = app(Role::class)->create(['name' => 'testAdminRole2', 'guard_name' => 'admin']); + $user4->assignRole($this->testAdminRole); + $user5->assignRole($this->testAdminRole); + $user6->assignRole($testAdminRole2); + $scopedUsers2 = Admin::withoutRole('testAdminRole', 'admin')->get(); + $scopedUsers3 = Admin::withoutRole('testAdminRole2', 'admin')->get(); + + $this->assertEquals(1, $scopedUsers2->count()); + $this->assertEquals(2, $scopedUsers3->count()); + } + /** @test */ public function it_throws_an_exception_when_trying_to_scope_a_role_from_another_guard() { @@ -487,6 +605,18 @@ public function it_throws_an_exception_when_trying_to_scope_a_role_from_another_ User::role($this->testAdminRole)->get(); } + /** @test */ + public function it_throws_an_exception_when_trying_to_call_withoutscope_on_a_role_from_another_guard() + { + $this->expectException(RoleDoesNotExist::class); + + User::withoutRole('testAdminRole')->get(); + + $this->expectException(GuardDoesNotMatch::class); + + User::withoutRole($this->testAdminRole)->get(); + } + /** @test */ public function it_throws_an_exception_when_trying_to_scope_a_non_existing_role() { @@ -495,6 +625,14 @@ public function it_throws_an_exception_when_trying_to_scope_a_non_existing_role( User::role('role not defined')->get(); } + /** @test */ + public function it_throws_an_exception_when_trying_to_use_withoutscope_on_a_non_existing_role() + { + $this->expectException(RoleDoesNotExist::class); + + User::withoutRole('role not defined')->get(); + } + /** @test */ public function it_can_determine_that_a_user_has_one_of_the_given_roles() { diff --git a/tests/TeamHasRolesTest.php b/tests/TeamHasRolesTest.php index e67010009..31ba19f53 100644 --- a/tests/TeamHasRolesTest.php +++ b/tests/TeamHasRolesTest.php @@ -133,15 +133,19 @@ public function it_can_scope_users_on_different_teams() setPermissionsTeamId(2); $scopedUsers1Team1 = User::role($this->testUserRole)->get(); $scopedUsers2Team1 = User::role(['testRole', 'testRole2'])->get(); + $scopedUsers3Team1 = User::withoutRole('testRole')->get(); $this->assertEquals(1, $scopedUsers1Team1->count()); $this->assertEquals(2, $scopedUsers2Team1->count()); + $this->assertEquals(1, $scopedUsers3Team1->count()); setPermissionsTeamId(1); $scopedUsers1Team2 = User::role($this->testUserRole)->get(); $scopedUsers2Team2 = User::role('testRole2')->get(); + $scopedUsers3Team2 = User::withoutRole('testRole')->get(); $this->assertEquals(1, $scopedUsers1Team2->count()); $this->assertEquals(0, $scopedUsers2Team2->count()); + $this->assertEquals(0, $scopedUsers3Team2->count()); } }