Skip to content

Commit 117d461

Browse files
authored
feat: Add user relations loading methods for groups and permissions (#1257)
* feat: Add user relations loading methods * update docs
1 parent 94cc0ce commit 117d461

File tree

7 files changed

+415
-2
lines changed

7 files changed

+415
-2
lines changed

docs/user_management/managing_users.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,42 @@ $user->fill([
7979
$users->save($user);
8080
```
8181

82+
### Listing Users
83+
84+
When displaying a list of users - for example, in the admin panel - we typically use the standard `find*` methods. However, these methods only return basic user information.
85+
86+
If you need additional details like email addresses, groups, or permissions, each piece of information will trigger a separate database query for every user. This happens because user entities lazy-load related data, which can quickly result in a large number of queries.
87+
88+
To optimize this, you can use method scopes like `UserModel::withIdentities()`, `withGroups()`, and `withPermissions()`. These methods preload the related data in a single query (one per each method), drastically reducing the number of database queries and improving performance.
89+
90+
```php
91+
// Get the User Provider (UserModel by default)
92+
$users = auth()->getProvider();
93+
94+
$usersList = $users
95+
->withIdentities()
96+
->withGroups()
97+
->withPermissions()
98+
->findAll(10);
99+
100+
// The below code would normally trigger an additional
101+
// DB queries, on every loop iteration, but now it won't
102+
103+
foreach ($usersList as $u) {
104+
// Because identities are preloaded
105+
echo $u->email;
106+
107+
// Because groups are preloaded
108+
$u->inGroup('admin');
109+
110+
// Because permissions are preloaded
111+
$u->hasPermission('users.delete');
112+
113+
// Because groups and permissions are preloaded
114+
$u->can('users.delete');
115+
}
116+
```
117+
82118
## Managing Users via CLI
83119

84120
Shield has a CLI command to manage users. You can do the following actions:

phpstan-baseline.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,12 @@
411411
$ignoreErrors[] = [
412412
'message' => '#^Call to function model with CodeIgniter\\\\Shield\\\\Models\\\\GroupModel\\:\\:class is discouraged\\.$#',
413413
'identifier' => 'codeigniter.factoriesClassConstFetch',
414+
'count' => 2,
415+
'path' => __DIR__ . '/src/Models/UserModel.php',
416+
];
417+
$ignoreErrors[] = [
418+
'message' => '#^Call to function model with CodeIgniter\\\\Shield\\\\Models\\\\PermissionModel\\:\\:class is discouraged\\.$#',
419+
'identifier' => 'codeigniter.factoriesClassConstFetch',
414420
'count' => 1,
415421
'path' => __DIR__ . '/src/Models/UserModel.php',
416422
];
@@ -502,7 +508,7 @@
502508
$ignoreErrors[] = [
503509
'message' => '#^Call to an undefined method CodeIgniter\\\\Shield\\\\Models\\\\UserModel\\:\\:getLastQuery\\(\\)\\.$#',
504510
'identifier' => 'method.notFound',
505-
'count' => 1,
511+
'count' => 7,
506512
'path' => __DIR__ . '/tests/Unit/UserTest.php',
507513
];
508514
$ignoreErrors[] = [
@@ -511,5 +517,11 @@
511517
'count' => 1,
512518
'path' => __DIR__ . '/tests/Unit/UserTest.php',
513519
];
520+
$ignoreErrors[] = [
521+
'message' => '#^Offset 1 does not exist on array\\{CodeIgniter\\\\Shield\\\\Entities\\\\User\\}\\.$#',
522+
'identifier' => 'offsetAccess.notFound',
523+
'count' => 5,
524+
'path' => __DIR__ . '/tests/Unit/UserTest.php',
525+
];
514526

515527
return ['parameters' => ['ignoreErrors' => $ignoreErrors]];

src/Authorization/Traits/Authorizable.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ public function syncGroups(string ...$groups): self
112112
return $this;
113113
}
114114

115+
/**
116+
* Set groups cache manually
117+
*/
118+
public function setGroupsCache(array $groups): void
119+
{
120+
$this->groupCache = $groups === [] ? null : $groups;
121+
}
122+
123+
/**
124+
* Set permissions cache manually
125+
*/
126+
public function setPermissionsCache(array $permissions): void
127+
{
128+
$this->permissionsCache = $permissions === [] ? null : $permissions;
129+
}
130+
115131
/**
116132
* Returns all groups this user is a part of.
117133
*/

src/Models/GroupModel.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,28 @@ public function isValidGroup(string $group): bool
8282

8383
return in_array($group, $allowedGroups, true);
8484
}
85+
86+
/**
87+
* @param list<int>|list<string> $userIds
88+
*
89+
* @return array<int, array>
90+
*/
91+
public function getGroupsByUserIds(array $userIds): array
92+
{
93+
$groups = $this->builder()
94+
->select('user_id, group')
95+
->whereIn('user_id', $userIds)
96+
->orderBy($this->primaryKey)
97+
->get()
98+
->getResultArray();
99+
100+
return array_map(
101+
'array_keys',
102+
array_reduce($groups, static function ($carry, $item) {
103+
$carry[$item['user_id']][$item['group']] = true;
104+
105+
return $carry;
106+
}, []),
107+
);
108+
}
85109
}

src/Models/PermissionModel.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,28 @@ public function deleteNotIn($userId, mixed $cache): void
7272

7373
$this->checkQueryReturn($return);
7474
}
75+
76+
/**
77+
* @param list<int>|list<string> $userIds
78+
*
79+
* @return array<int, array>
80+
*/
81+
public function getPermissionsByUserIds(array $userIds): array
82+
{
83+
$permissions = $this->builder()
84+
->select('user_id, permission')
85+
->whereIn('user_id', $userIds)
86+
->orderBy($this->primaryKey)
87+
->get()
88+
->getResultArray();
89+
90+
return array_map(
91+
'array_keys',
92+
array_reduce($permissions, static function ($carry, $item) {
93+
$carry[$item['user_id']][$item['permission']] = true;
94+
95+
return $carry;
96+
}, []),
97+
);
98+
}
7599
}

src/Models/UserModel.php

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class UserModel extends BaseModel
3939
'last_active',
4040
];
4141
protected $useTimestamps = true;
42-
protected $afterFind = ['fetchIdentities'];
42+
protected $afterFind = ['fetchIdentities', 'fetchGroups', 'fetchPermissions'];
4343
protected $afterInsert = ['saveEmailIdentity'];
4444
protected $afterUpdate = ['saveEmailIdentity'];
4545

@@ -49,6 +49,18 @@ class UserModel extends BaseModel
4949
*/
5050
protected bool $fetchIdentities = false;
5151

52+
/**
53+
* Whether groups should be included
54+
* when user records are fetched from the database.
55+
*/
56+
protected bool $fetchGroups = false;
57+
58+
/**
59+
* Whether permissions should be included
60+
* when user records are fetched from the database.
61+
*/
62+
protected bool $fetchPermissions = false;
63+
5264
/**
5365
* Save the User for afterInsert and afterUpdate
5466
*/
@@ -73,6 +85,30 @@ public function withIdentities(): self
7385
return $this;
7486
}
7587

88+
/**
89+
* Mark the next find* query to include groups
90+
*
91+
* @return $this
92+
*/
93+
public function withGroups(): self
94+
{
95+
$this->fetchGroups = true;
96+
97+
return $this;
98+
}
99+
100+
/**
101+
* Mark the next find* query to include permissions
102+
*
103+
* @return $this
104+
*/
105+
public function withPermissions(): self
106+
{
107+
$this->fetchPermissions = true;
108+
109+
return $this;
110+
}
111+
76112
/**
77113
* Populates identities for all records
78114
* returned from a find* method. Called
@@ -147,6 +183,113 @@ private function assignIdentities(array $data, array $identities): array
147183
return $mappedUsers;
148184
}
149185

186+
/**
187+
* Populates groups for all records
188+
* returned from a find* method. Called
189+
* automatically when $this->fetchGroups == true
190+
*
191+
* Model event callback called by `afterFind`.
192+
*/
193+
protected function fetchGroups(array $data): array
194+
{
195+
if (! $this->fetchGroups) {
196+
return $data;
197+
}
198+
199+
$userIds = $data['singleton']
200+
? array_column($data, 'id')
201+
: array_column($data['data'], 'id');
202+
203+
if ($userIds === []) {
204+
return $data;
205+
}
206+
207+
/** @var GroupModel $groupModel */
208+
$groupModel = model(GroupModel::class);
209+
210+
// Get our groups for all users
211+
$groups = $groupModel->getGroupsByUserIds($userIds);
212+
213+
if ($groups === []) {
214+
return $data;
215+
}
216+
217+
$mappedUsers = $this->assignProperties($data, $groups, 'groups');
218+
219+
$data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
220+
221+
return $data;
222+
}
223+
224+
/**
225+
* Populates permissions for all records
226+
* returned from a find* method. Called
227+
* automatically when $this->fetchPermissions == true
228+
*
229+
* Model event callback called by `afterFind`.
230+
*/
231+
protected function fetchPermissions(array $data): array
232+
{
233+
if (! $this->fetchPermissions) {
234+
return $data;
235+
}
236+
237+
$userIds = $data['singleton']
238+
? array_column($data, 'id')
239+
: array_column($data['data'], 'id');
240+
241+
if ($userIds === []) {
242+
return $data;
243+
}
244+
245+
/** @var PermissionModel $permissionModel */
246+
$permissionModel = model(PermissionModel::class);
247+
248+
$permissions = $permissionModel->getPermissionsByUserIds($userIds);
249+
250+
if ($permissions === []) {
251+
return $data;
252+
}
253+
254+
$mappedUsers = $this->assignProperties($data, $permissions, 'permissions');
255+
256+
$data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
257+
258+
return $data;
259+
}
260+
261+
/**
262+
* Map our users by ID to make assigning simpler
263+
*
264+
* @param array $data Event $data
265+
* @param list<array> $properties
266+
* @param string $type One of: 'groups' or 'permissions'
267+
*
268+
* @return list<User> UserId => User object
269+
*/
270+
private function assignProperties(array $data, array $properties, string $type): array
271+
{
272+
$mappedUsers = [];
273+
274+
$users = $data['singleton'] ? [$data['data']] : $data['data'];
275+
276+
foreach ($users as $user) {
277+
$mappedUsers[$user->id] = $user;
278+
}
279+
unset($users);
280+
281+
// Build method name
282+
$method = 'set' . ucfirst($type) . 'Cache';
283+
284+
// Now assign the properties to the user
285+
foreach ($properties as $userId => $propertyArray) {
286+
$mappedUsers[$userId]->{$method}($propertyArray);
287+
}
288+
unset($properties);
289+
290+
return $mappedUsers;
291+
}
292+
150293
/**
151294
* Adds a user to the default group.
152295
* Used during registration.

0 commit comments

Comments
 (0)