• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / shield / 16096447163

06 Jul 2025 07:05AM UTC coverage: 92.855% (+0.1%) from 92.756%
16096447163

push

github

web-flow
feat: Add user relations loading methods for groups and permissions (#1257)

* feat: Add user relations loading methods

* update docs

68 of 70 new or added lines in 4 files covered. (97.14%)

2898 of 3121 relevant lines covered (92.85%)

152.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.09
/src/Models/UserModel.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter Shield.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Shield\Models;
15

16
use CodeIgniter\Database\Exceptions\DataException;
17
use CodeIgniter\I18n\Time;
18
use CodeIgniter\Shield\Authentication\Authenticators\Session;
19
use CodeIgniter\Shield\Entities\User;
20
use CodeIgniter\Shield\Entities\UserIdentity;
21
use CodeIgniter\Shield\Exceptions\InvalidArgumentException;
22
use CodeIgniter\Shield\Exceptions\LogicException;
23
use CodeIgniter\Shield\Exceptions\ValidationException;
24
use Faker\Generator;
25

26
/**
27
 * @phpstan-consistent-constructor
28
 */
29
class UserModel extends BaseModel
30
{
31
    protected $primaryKey     = 'id';
32
    protected $returnType     = User::class;
33
    protected $useSoftDeletes = true;
34
    protected $allowedFields  = [
35
        'username',
36
        'status',
37
        'status_message',
38
        'active',
39
        'last_active',
40
    ];
41
    protected $useTimestamps = true;
42
    protected $afterFind     = ['fetchIdentities', 'fetchGroups', 'fetchPermissions'];
43
    protected $afterInsert   = ['saveEmailIdentity'];
44
    protected $afterUpdate   = ['saveEmailIdentity'];
45

46
    /**
47
     * Whether identity records should be included
48
     * when user records are fetched from the database.
49
     */
50
    protected bool $fetchIdentities = false;
51

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

64
    /**
65
     * Save the User for afterInsert and afterUpdate
66
     */
67
    protected ?User $tempUser = null;
68

69
    protected function initialize(): void
70
    {
71
        parent::initialize();
1,764✔
72

73
        $this->table = $this->tables['users'];
1,764✔
74
    }
75

76
    /**
77
     * Mark the next find* query to include identities
78
     *
79
     * @return $this
80
     */
81
    public function withIdentities(): self
82
    {
83
        $this->fetchIdentities = true;
30✔
84

85
        return $this;
30✔
86
    }
87

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;
36✔
96

97
        return $this;
36✔
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;
36✔
108

109
        return $this;
36✔
110
    }
111

112
    /**
113
     * Populates identities for all records
114
     * returned from a find* method. Called
115
     * automatically when $this->fetchIdentities == true
116
     *
117
     * Model event callback called by `afterFind`.
118
     */
119
    protected function fetchIdentities(array $data): array
120
    {
121
        if (! $this->fetchIdentities) {
1,524✔
122
            return $data;
1,524✔
123
        }
124

125
        $userIds = $data['singleton']
30✔
126
            ? array_column($data, 'id')
12✔
127
            : array_column($data['data'], 'id');
18✔
128

129
        if ($userIds === []) {
30✔
130
            return $data;
12✔
131
        }
132

133
        /** @var UserIdentityModel $identityModel */
134
        $identityModel = model(UserIdentityModel::class);
18✔
135

136
        // Get our identities for all users
137
        $identities = $identityModel->getIdentitiesByUserIds($userIds);
18✔
138

139
        if (empty($identities)) {
18✔
140
            return $data;
×
141
        }
142

143
        $mappedUsers = $this->assignIdentities($data, $identities);
18✔
144

145
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
18✔
146

147
        return $data;
18✔
148
    }
149

150
    /**
151
     * Map our users by ID to make assigning simpler
152
     *
153
     * @param array              $data       Event $data
154
     * @param list<UserIdentity> $identities
155
     *
156
     * @return         list<User>              UserId => User object
157
     * @phpstan-return array<int|string, User> UserId => User object
158
     */
159
    private function assignIdentities(array $data, array $identities): array
160
    {
161
        $mappedUsers    = [];
18✔
162
        $userIdentities = [];
18✔
163

164
        $users = $data['singleton'] ? [$data['data']] : $data['data'];
18✔
165

166
        foreach ($users as $user) {
18✔
167
            $mappedUsers[$user->id] = $user;
18✔
168
        }
169
        unset($users);
18✔
170

171
        // Now group the identities by user
172
        foreach ($identities as $identity) {
18✔
173
            $userIdentities[$identity->user_id][] = $identity;
18✔
174
        }
175
        unset($identities);
18✔
176

177
        // Now assign the identities to the user
178
        foreach ($userIdentities as $userId => $identityArray) {
18✔
179
            $mappedUsers[$userId]->identities = $identityArray;
18✔
180
        }
181
        unset($userIdentities);
18✔
182

183
        return $mappedUsers;
18✔
184
    }
185

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) {
1,524✔
196
            return $data;
1,524✔
197
        }
198

199
        $userIds = $data['singleton']
36✔
200
            ? array_column($data, 'id')
18✔
201
            : array_column($data['data'], 'id');
18✔
202

203
        if ($userIds === []) {
36✔
204
            return $data;
12✔
205
        }
206

207
        /** @var GroupModel $groupModel */
208
        $groupModel = model(GroupModel::class);
24✔
209

210
        // Get our groups for all users
211
        $groups = $groupModel->getGroupsByUserIds($userIds);
24✔
212

213
        if ($groups === []) {
24✔
NEW
214
            return $data;
×
215
        }
216

217
        $mappedUsers = $this->assignProperties($data, $groups, 'groups');
24✔
218

219
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
24✔
220

221
        return $data;
24✔
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) {
1,524✔
234
            return $data;
1,524✔
235
        }
236

237
        $userIds = $data['singleton']
36✔
238
            ? array_column($data, 'id')
18✔
239
            : array_column($data['data'], 'id');
18✔
240

241
        if ($userIds === []) {
36✔
242
            return $data;
12✔
243
        }
244

245
        /** @var PermissionModel $permissionModel */
246
        $permissionModel = model(PermissionModel::class);
24✔
247

248
        $permissions = $permissionModel->getPermissionsByUserIds($userIds);
24✔
249

250
        if ($permissions === []) {
24✔
NEW
251
            return $data;
×
252
        }
253

254
        $mappedUsers = $this->assignProperties($data, $permissions, 'permissions');
24✔
255

256
        $data['data'] = $data['singleton'] ? $mappedUsers[$data['id']] : $mappedUsers;
24✔
257

258
        return $data;
24✔
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 = [];
36✔
273

274
        $users = $data['singleton'] ? [$data['data']] : $data['data'];
36✔
275

276
        foreach ($users as $user) {
36✔
277
            $mappedUsers[$user->id] = $user;
36✔
278
        }
279
        unset($users);
36✔
280

281
        // Build method name
282
        $method = 'set' . ucfirst($type) . 'Cache';
36✔
283

284
        // Now assign the properties to the user
285
        foreach ($properties as $userId => $propertyArray) {
36✔
286
            $mappedUsers[$userId]->{$method}($propertyArray);
36✔
287
        }
288
        unset($properties);
36✔
289

290
        return $mappedUsers;
36✔
291
    }
292

293
    /**
294
     * Adds a user to the default group.
295
     * Used during registration.
296
     */
297
    public function addToDefaultGroup(User $user): void
298
    {
299
        $defaultGroup = setting('AuthGroups.defaultGroup');
42✔
300

301
        /** @var GroupModel $groupModel */
302
        $groupModel = model(GroupModel::class);
42✔
303

304
        if (empty($defaultGroup) || ! $groupModel->isValidGroup($defaultGroup)) {
42✔
305
            throw new InvalidArgumentException(lang('Auth.unknownGroup', [$defaultGroup ?? '--not found--']));
×
306
        }
307

308
        $user->addGroup($defaultGroup);
42✔
309
    }
310

311
    public function fake(Generator &$faker): User
312
    {
313
        $this->checkReturnType();
1,482✔
314

315
        return new $this->returnType([
1,482✔
316
            'username' => $faker->unique()->userName(),
1,482✔
317
            'active'   => true,
1,482✔
318
        ]);
1,482✔
319
    }
320

321
    /**
322
     * Locates a User object by ID.
323
     *
324
     * @param int|string $id
325
     */
326
    public function findById($id): ?User
327
    {
328
        return $this->find($id);
528✔
329
    }
330

331
    /**
332
     * Locate a User object by the given credentials.
333
     *
334
     * @param array<string, string> $credentials
335
     */
336
    public function findByCredentials(array $credentials): ?User
337
    {
338
        // Email is stored in an identity so remove that here
339
        $email = $credentials['email'] ?? null;
288✔
340
        unset($credentials['email']);
288✔
341

342
        if ($email === null && $credentials === []) {
288✔
343
            return null;
6✔
344
        }
345

346
        // any of the credentials used should be case-insensitive
347
        foreach ($credentials as $key => $value) {
288✔
348
            $this->where(
18✔
349
                'LOWER(' . $this->db->protectIdentifiers($this->table . ".{$key}") . ')',
18✔
350
                strtolower($value),
18✔
351
            );
18✔
352
        }
353

354
        if ($email !== null) {
288✔
355
            /** @var array<string, int|string|null>|null $data */
356
            $data = $this->select(
270✔
357
                sprintf('%1$s.*, %2$s.secret as email, %2$s.secret2 as password_hash', $this->table, $this->tables['identities']),
270✔
358
            )
270✔
359
                ->join($this->tables['identities'], sprintf('%1$s.user_id = %2$s.id', $this->tables['identities'], $this->table))
270✔
360
                ->where($this->tables['identities'] . '.type', Session::ID_TYPE_EMAIL_PASSWORD)
270✔
361
                ->where(
270✔
362
                    'LOWER(' . $this->db->protectIdentifiers($this->tables['identities'] . '.secret') . ')',
270✔
363
                    strtolower($email),
270✔
364
                )
270✔
365
                ->asArray()
270✔
366
                ->first();
270✔
367

368
            if ($data === null) {
270✔
369
                return null;
66✔
370
            }
371

372
            $email = $data['email'];
204✔
373
            unset($data['email']);
204✔
374
            $password_hash = $data['password_hash'];
204✔
375
            unset($data['password_hash']);
204✔
376

377
            $this->checkReturnType();
204✔
378

379
            $user                = new $this->returnType($data);
204✔
380
            $user->email         = $email;
204✔
381
            $user->password_hash = $password_hash;
204✔
382
            $user->syncOriginal();
204✔
383

384
            return $user;
204✔
385
        }
386

387
        return $this->first();
18✔
388
    }
389

390
    /**
391
     * Activate a User.
392
     */
393
    public function activate(User $user): void
394
    {
395
        $user->active = true;
×
396

397
        $this->save($user);
×
398
    }
399

400
    /**
401
     * Override the BaseModel's `insert()` method.
402
     * If you pass User object, also inserts Email Identity.
403
     *
404
     * @param array|User $row
405
     *
406
     * @return int|string|true Insert ID if $returnID is true
407
     *
408
     * @throws ValidationException
409
     */
410
    public function insert($row = null, bool $returnID = true)
411
    {
412
        // Clone User object for not changing the passed object.
413
        $this->tempUser = $row instanceof User ? clone $row : null;
1,518✔
414

415
        $result = parent::insert($row, $returnID);
1,518✔
416

417
        $this->checkQueryReturn($result);
1,518✔
418

419
        return $returnID ? $this->insertID : $result;
1,518✔
420
    }
421

422
    /**
423
     * Override the BaseModel's `update()` method.
424
     * If you pass User object, also updates Email Identity.
425
     *
426
     * @param array|int|string|null $id
427
     * @param array|User            $row
428
     *
429
     * @return true if the update is successful
430
     *
431
     * @throws ValidationException
432
     */
433
    public function update($id = null, $row = null): bool
434
    {
435
        // Clone User object for not changing the passed object.
436
        $this->tempUser = $row instanceof User ? clone $row : null;
180✔
437

438
        try {
439
            /** @throws DataException */
440
            $result = parent::update($id, $row);
180✔
441
        } catch (DataException $e) {
50✔
442
            // When $data is an array.
443
            if ($this->tempUser === null) {
50✔
444
                throw $e;
6✔
445
            }
446

447
            $messages = [
44✔
448
                lang('Database.emptyDataset', ['update']),
44✔
449
            ];
44✔
450

451
            if (in_array($e->getMessage(), $messages, true)) {
44✔
452
                $this->tempUser->saveEmailIdentity();
44✔
453

454
                return true;
44✔
455
            }
456

457
            throw $e;
×
458
        }
459

460
        $this->checkQueryReturn($result);
144✔
461

462
        return true;
144✔
463
    }
464

465
    /**
466
     * Override the BaseModel's `save()` method.
467
     * If you pass User object, also updates Email Identity.
468
     *
469
     * @param array|User $row
470
     *
471
     * @return true if the save is successful
472
     *
473
     * @throws ValidationException
474
     */
475
    public function save($row): bool
476
    {
477
        $result = parent::save($row);
222✔
478

479
        $this->checkQueryReturn($result);
216✔
480

481
        return true;
216✔
482
    }
483

484
    /**
485
     * Save Email Identity
486
     *
487
     * Model event callback called by `afterInsert` and `afterUpdate`.
488
     */
489
    protected function saveEmailIdentity(array $data): array
490
    {
491
        // If insert()/update() gets an array data, do nothing.
492
        if ($this->tempUser === null) {
1,518✔
493
            return $data;
60✔
494
        }
495

496
        // Insert
497
        if ($this->tempUser->id === null) {
1,512✔
498
            /** @var User $user */
499
            $user = $this->find($this->db->insertID());
1,470✔
500

501
            // If you get identity (email/password), the User object must have the id.
502
            $this->tempUser->id = $user->id;
1,470✔
503

504
            $user->email         = $this->tempUser->email ?? '';
1,470✔
505
            $user->password      = $this->tempUser->password ?? '';
1,470✔
506
            $user->password_hash = $this->tempUser->password_hash ?? '';
1,470✔
507

508
            $user->saveEmailIdentity();
1,470✔
509
            $this->tempUser = null;
1,470✔
510

511
            return $data;
1,470✔
512
        }
513

514
        // Update
515
        $this->tempUser->saveEmailIdentity();
154✔
516
        $this->tempUser = null;
154✔
517

518
        return $data;
154✔
519
    }
520

521
    /**
522
     * Updates the user's last active date.
523
     */
524
    public function updateActiveDate(User $user): void
525
    {
526
        assert($user->last_active instanceof Time);
527

528
        // Safe date string for database
529
        $last_active = $this->timeToDate($user->last_active);
78✔
530

531
        $this->builder()
78✔
532
            ->set('last_active', $last_active)
78✔
533
            ->where('id', $user->id)
78✔
534
            ->update();
78✔
535
    }
536

537
    private function checkReturnType(): void
538
    {
539
        if (! is_a($this->returnType, User::class, true)) {
1,536✔
540
            throw new LogicException('Return type must be a subclass of ' . User::class);
×
541
        }
542
    }
543

544
    /**
545
     * Returns a new User Entity.
546
     *
547
     * @param array<string, array<array-key, mixed>|bool|float|int|object|string|null> $data (Optional) user data
548
     */
549
    public function createNewUser(array $data = []): User
550
    {
551
        return new $this->returnType($data);
36✔
552
    }
553
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc