Skip to content

feat: Add user relations loading methods for groups and permissions #1257

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/user_management/managing_users.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,42 @@ $user->fill([
$users->save($user);
```

### Listing Users

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.

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.

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.

```php
// Get the User Provider (UserModel by default)
$users = auth()->getProvider();

$usersList = $users
->withIdentities()
->withGroups()
->withPermissions()
->findAll(10);

// The below code would normally trigger an additional
// DB queries, on every loop iteration, but now it won't

foreach ($usersList as $u) {
// Because identities are preloaded
echo $u->email;

// Because groups are preloaded
$u->inGroup('admin');

// Because permissions are preloaded
$u->hasPermission('users.delete');

// Because groups and permissions are preloaded
$u->can('users.delete');
}
```

## Managing Users via CLI

Shield has a CLI command to manage users. You can do the following actions:
Expand Down
14 changes: 13 additions & 1 deletion phpstan-baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,12 @@
$ignoreErrors[] = [
'message' => '#^Call to function model with CodeIgniter\\\\Shield\\\\Models\\\\GroupModel\\:\\:class is discouraged\\.$#',
'identifier' => 'codeigniter.factoriesClassConstFetch',
'count' => 2,
'path' => __DIR__ . '/src/Models/UserModel.php',
];
$ignoreErrors[] = [
'message' => '#^Call to function model with CodeIgniter\\\\Shield\\\\Models\\\\PermissionModel\\:\\:class is discouraged\\.$#',
'identifier' => 'codeigniter.factoriesClassConstFetch',
'count' => 1,
'path' => __DIR__ . '/src/Models/UserModel.php',
];
Expand Down Expand Up @@ -489,7 +495,7 @@
$ignoreErrors[] = [
'message' => '#^Call to an undefined method CodeIgniter\\\\Shield\\\\Models\\\\UserModel\\:\\:getLastQuery\\(\\)\\.$#',
'identifier' => 'method.notFound',
'count' => 1,
'count' => 7,
'path' => __DIR__ . '/tests/Unit/UserTest.php',
];
$ignoreErrors[] = [
Expand All @@ -498,5 +504,11 @@
'count' => 1,
'path' => __DIR__ . '/tests/Unit/UserTest.php',
];
$ignoreErrors[] = [
'message' => '#^Offset 1 does not exist on array\\{CodeIgniter\\\\Shield\\\\Entities\\\\User\\}\\.$#',
'identifier' => 'offsetAccess.notFound',
'count' => 5,
'path' => __DIR__ . '/tests/Unit/UserTest.php',
];

return ['parameters' => ['ignoreErrors' => $ignoreErrors]];
1 change: 1 addition & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
errorBaseline="psalm-baseline.xml"
findUnusedBaselineEntry="false"
findUnusedCode="false"
ensureOverrideAttribute="false"
>
<projectFiles>
<directory name="src/" />
Expand Down
16 changes: 16 additions & 0 deletions src/Authorization/Traits/Authorizable.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,22 @@ public function syncGroups(string ...$groups): self
return $this;
}

/**
* Set groups cache manually
*/
public function setGroupsCache(array $groups): void
{
$this->groupCache = $groups === [] ? null : $groups;
}

/**
* Set permissions cache manually
*/
public function setPermissionsCache(array $permissions): void
{
$this->permissionsCache = $permissions === [] ? null : $permissions;
}

/**
* Returns all groups this user is a part of.
*/
Expand Down
24 changes: 24 additions & 0 deletions src/Models/GroupModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,28 @@ public function isValidGroup(string $group): bool

return in_array($group, $allowedGroups, true);
}

/**
* @param list<int>|list<string> $userIds
*
* @return array<int, array>
*/
public function getGroupsByUserIds(array $userIds): array
{
$groups = $this->builder()
->select('user_id, group')
->whereIn('user_id', $userIds)
->orderBy($this->primaryKey)
->get()
->getResultArray();

return array_map(
'array_keys',
array_reduce($groups, static function ($carry, $item) {
$carry[$item['user_id']][$item['group']] = true;

return $carry;
}, []),
);
}
}
24 changes: 24 additions & 0 deletions src/Models/PermissionModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,28 @@ public function deleteNotIn($userId, mixed $cache): void

$this->checkQueryReturn($return);
}

/**
* @param list<int>|list<string> $userIds
*
* @return array<int, array>
*/
public function getPermissionsByUserIds(array $userIds): array
{
$permissions = $this->builder()
->select('user_id, permission')
->whereIn('user_id', $userIds)
->orderBy($this->primaryKey)
->get()
->getResultArray();

return array_map(
'array_keys',
array_reduce($permissions, static function ($carry, $item) {
$carry[$item['user_id']][$item['permission']] = true;

return $carry;
}, []),
);
}
}
145 changes: 144 additions & 1 deletion src/Models/UserModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class UserModel extends BaseModel
'last_active',
];
protected $useTimestamps = true;
protected $afterFind = ['fetchIdentities'];
protected $afterFind = ['fetchIdentities', 'fetchGroups', 'fetchPermissions'];
protected $afterInsert = ['saveEmailIdentity'];
protected $afterUpdate = ['saveEmailIdentity'];

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

/**
* Whether groups should be included
* when user records are fetched from the database.
*/
protected bool $fetchGroups = false;

/**
* Whether permissions should be included
* when user records are fetched from the database.
*/
protected bool $fetchPermissions = false;

/**
* Save the User for afterInsert and afterUpdate
*/
Expand All @@ -73,6 +85,30 @@ public function withIdentities(): self
return $this;
}

/**
* Mark the next find* query to include groups
*
* @return $this
*/
public function withGroups(): self
{
$this->fetchGroups = true;

return $this;
}

/**
* Mark the next find* query to include permissions
*
* @return $this
*/
public function withPermissions(): self
{
$this->fetchPermissions = true;

return $this;
}

/**
* Populates identities for all records
* returned from a find* method. Called
Expand Down Expand Up @@ -147,6 +183,113 @@ private function assignIdentities(array $data, array $identities): array
return $mappedUsers;
}

/**
* Populates groups for all records
* returned from a find* method. Called
* automatically when $this->fetchGroups == true
*
* Model event callback called by `afterFind`.
*/
protected function fetchGroups(array $data): array
{
if (! $this->fetchGroups) {
return $data;
}

$userIds = $data['singleton']
? array_column($data, 'id')
: array_column($data['data'], 'id');

if ($userIds === []) {
return $data;
}

/** @var GroupModel $groupModel */
$groupModel = model(GroupModel::class);

// Get our groups for all users
$groups = $groupModel->getGroupsByUserIds($userIds);

if ($groups === []) {
return $data;
}

$mappedUsers = $this->assignProperties($data, $groups, 'groups');

$data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;

return $data;
}

/**
* Populates permissions for all records
* returned from a find* method. Called
* automatically when $this->fetchPermissions == true
*
* Model event callback called by `afterFind`.
*/
protected function fetchPermissions(array $data): array
{
if (! $this->fetchPermissions) {
return $data;
}

$userIds = $data['singleton']
? array_column($data, 'id')
: array_column($data['data'], 'id');

if ($userIds === []) {
return $data;
}

/** @var PermissionModel $permissionModel */
$permissionModel = model(PermissionModel::class);

$permissions = $permissionModel->getPermissionsByUserIds($userIds);

if ($permissions === []) {
return $data;
}

$mappedUsers = $this->assignProperties($data, $permissions, 'permissions');

$data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;

return $data;
}

/**
* Map our users by ID to make assigning simpler
*
* @param array $data Event $data
* @param list<array> $properties
* @param string $type One of: 'groups' or 'permissions'
*
* @return list<User> UserId => User object
*/
private function assignProperties(array $data, array $properties, string $type): array
{
$mappedUsers = [];

$users = $data['singleton'] ? [$data['data']] : $data['data'];

foreach ($users as $user) {
$mappedUsers[$user->id] = $user;
}
unset($users);

// Build method name
$method = 'set' . ucfirst($type) . 'Cache';

// Now assign the properties to the user
foreach ($properties as $userId => $propertyArray) {
$mappedUsers[$userId]->{$method}($propertyArray);
}
unset($properties);

return $mappedUsers;
}

/**
* Adds a user to the default group.
* Used during registration.
Expand Down
Loading
Loading