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

codeigniter4 / CodeIgniter4 / 18982097219

31 Oct 2025 06:43PM UTC coverage: 84.53% (+0.03%) from 84.503%
18982097219

Pull #9779

github

web-flow
Merge 0296b7225 into 75962790d
Pull Request #9779: feat(entity): deep change tracking for objects and arrays

48 of 48 new or added lines in 1 file covered. (100.0%)

3 existing lines in 1 file now uncovered.

21496 of 25430 relevant lines covered (84.53%)

196.22 hits per line

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

96.45
/system/Entity/Entity.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
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\Entity;
15

16
use BackedEnum;
17
use CodeIgniter\DataCaster\DataCaster;
18
use CodeIgniter\Entity\Cast\ArrayCast;
19
use CodeIgniter\Entity\Cast\BooleanCast;
20
use CodeIgniter\Entity\Cast\CSVCast;
21
use CodeIgniter\Entity\Cast\DatetimeCast;
22
use CodeIgniter\Entity\Cast\EnumCast;
23
use CodeIgniter\Entity\Cast\FloatCast;
24
use CodeIgniter\Entity\Cast\IntBoolCast;
25
use CodeIgniter\Entity\Cast\IntegerCast;
26
use CodeIgniter\Entity\Cast\JsonCast;
27
use CodeIgniter\Entity\Cast\ObjectCast;
28
use CodeIgniter\Entity\Cast\StringCast;
29
use CodeIgniter\Entity\Cast\TimestampCast;
30
use CodeIgniter\Entity\Cast\URICast;
31
use CodeIgniter\Entity\Exceptions\CastException;
32
use CodeIgniter\I18n\Time;
33
use DateTime;
34
use DateTimeInterface;
35
use Exception;
36
use JsonSerializable;
37
use ReturnTypeWillChange;
38
use UnitEnum;
39

40
/**
41
 * Entity encapsulation, for use with CodeIgniter\Model
42
 *
43
 * @see \CodeIgniter\Entity\EntityTest
44
 */
45
class Entity implements JsonSerializable
46
{
47
    /**
48
     * Maps names used in sets and gets against unique
49
     * names within the class, allowing independence from
50
     * database column names.
51
     *
52
     * Example:
53
     *  $datamap = [
54
     *      'class_property_name' => 'db_column_name'
55
     *  ];
56
     *
57
     * @var array<string, string>
58
     */
59
    protected $datamap = [];
60

61
    /**
62
     * The date fields.
63
     *
64
     * @var list<string>
65
     */
66
    protected $dates = [
67
        'created_at',
68
        'updated_at',
69
        'deleted_at',
70
    ];
71

72
    /**
73
     * Array of field names and the type of value to cast them as when
74
     * they are accessed.
75
     *
76
     * @var array<string, string>
77
     */
78
    protected $casts = [];
79

80
    /**
81
     * Custom convert handlers
82
     *
83
     * @var array<string, string>
84
     */
85
    protected $castHandlers = [];
86

87
    /**
88
     * Default convert handlers
89
     *
90
     * @var array<string, string>
91
     */
92
    private array $defaultCastHandlers = [
93
        'array'     => ArrayCast::class,
94
        'bool'      => BooleanCast::class,
95
        'boolean'   => BooleanCast::class,
96
        'csv'       => CSVCast::class,
97
        'datetime'  => DatetimeCast::class,
98
        'double'    => FloatCast::class,
99
        'enum'      => EnumCast::class,
100
        'float'     => FloatCast::class,
101
        'int'       => IntegerCast::class,
102
        'integer'   => IntegerCast::class,
103
        'int-bool'  => IntBoolCast::class,
104
        'json'      => JsonCast::class,
105
        'object'    => ObjectCast::class,
106
        'string'    => StringCast::class,
107
        'timestamp' => TimestampCast::class,
108
        'uri'       => URICast::class,
109
    ];
110

111
    /**
112
     * Holds the current values of all class vars.
113
     *
114
     * @var array<string, mixed>
115
     */
116
    protected $attributes = [];
117

118
    /**
119
     * Holds original copies of all class vars so we can determine
120
     * what's actually been changed and not accidentally write
121
     * nulls where we shouldn't.
122
     *
123
     * @var array<string, mixed>
124
     */
125
    protected $original = [];
126

127
    /**
128
     * The data caster.
129
     */
130
    protected DataCaster $dataCaster;
131

132
    /**
133
     * Holds info whenever properties have to be casted
134
     */
135
    private bool $_cast = true;
136

137
    /**
138
     * Indicates whether all attributes are scalars (for optimization)
139
     */
140
    private bool $_onlyScalars = true;
141

142
    /**
143
     * Allows filling in Entity parameters during construction.
144
     */
145
    public function __construct(?array $data = null)
146
    {
147
        $this->dataCaster = new DataCaster(
157✔
148
            array_merge($this->defaultCastHandlers, $this->castHandlers),
157✔
149
            null,
157✔
150
            null,
157✔
151
            false,
157✔
152
        );
157✔
153

154
        $this->syncOriginal();
157✔
155

156
        $this->fill($data);
157✔
157
    }
158

159
    /**
160
     * Takes an array of key/value pairs and sets them as class
161
     * properties, using any `setCamelCasedProperty()` methods
162
     * that may or may not exist.
163
     *
164
     * @param array<string, array|bool|float|int|object|string|null> $data
165
     *
166
     * @return $this
167
     */
168
    public function fill(?array $data = null)
169
    {
170
        if (! is_array($data)) {
157✔
171
            return $this;
151✔
172
        }
173

174
        foreach ($data as $key => $value) {
18✔
175
            $this->__set($key, $value);
18✔
176
        }
177

178
        return $this;
18✔
179
    }
180

181
    /**
182
     * General method that will return all public and protected values
183
     * of this entity as an array. All values are accessed through the
184
     * __get() magic method so will have any casts, etc applied to them.
185
     *
186
     * @param bool $onlyChanged If true, only return values that have changed since object creation
187
     * @param bool $cast        If true, properties will be cast.
188
     * @param bool $recursive   If true, inner entities will be cast as array as well.
189
     */
190
    public function toArray(bool $onlyChanged = false, bool $cast = true, bool $recursive = false): array
191
    {
192
        $this->_cast = $cast;
10✔
193

194
        $keys = array_filter(array_keys($this->attributes), static fn ($key): bool => ! str_starts_with($key, '_'));
10✔
195

196
        if (is_array($this->datamap)) {
10✔
197
            $keys = array_unique(
10✔
198
                [...array_diff($keys, $this->datamap), ...array_keys($this->datamap)],
10✔
199
            );
10✔
200
        }
201

202
        $return = [];
10✔
203

204
        // Loop over the properties, to allow magic methods to do their thing.
205
        foreach ($keys as $key) {
10✔
206
            if ($onlyChanged && ! $this->hasChanged($key)) {
10✔
207
                continue;
2✔
208
            }
209

210
            $return[$key] = $this->__get($key);
10✔
211

212
            if ($recursive) {
10✔
213
                if ($return[$key] instanceof self) {
1✔
214
                    $return[$key] = $return[$key]->toArray($onlyChanged, $cast, $recursive);
1✔
215
                } elseif (is_callable([$return[$key], 'toArray'])) {
1✔
UNCOV
216
                    $return[$key] = $return[$key]->toArray();
×
217
                }
218
            }
219
        }
220

221
        $this->_cast = true;
10✔
222

223
        return $return;
10✔
224
    }
225

226
    /**
227
     * Returns the raw values of the current attributes.
228
     *
229
     * @param bool $onlyChanged If true, only return values that have changed since object creation
230
     * @param bool $recursive   If true, inner entities will be cast as array as well.
231
     */
232
    public function toRawArray(bool $onlyChanged = false, bool $recursive = false): array
233
    {
234
        $return = [];
39✔
235

236
        if (! $onlyChanged) {
39✔
237
            if ($recursive) {
31✔
238
                return array_map(static function ($value) use ($onlyChanged, $recursive) {
3✔
239
                    if ($value instanceof self) {
3✔
240
                        $value = $value->toRawArray($onlyChanged, $recursive);
1✔
241
                    } elseif (is_callable([$value, 'toRawArray'])) {
3✔
UNCOV
242
                        $value = $value->toRawArray();
×
243
                    }
244

245
                    return $value;
3✔
246
                }, $this->attributes);
3✔
247
            }
248

249
            return $this->attributes;
28✔
250
        }
251

252
        foreach ($this->attributes as $key => $value) {
11✔
253
            if (! $this->hasChanged($key)) {
11✔
254
                continue;
9✔
255
            }
256

257
            if ($recursive) {
11✔
UNCOV
258
                if ($value instanceof self) {
×
259
                    $value = $value->toRawArray($onlyChanged, $recursive);
×
260
                } elseif (is_callable([$value, 'toRawArray'])) {
×
261
                    $value = $value->toRawArray();
×
262
                }
263
            }
264

265
            $return[$key] = $value;
11✔
266
        }
267

268
        return $return;
11✔
269
    }
270

271
    /**
272
     * Ensures our "original" values match the current values.
273
     *
274
     * Objects and arrays are normalized and JSON-encoded for reliable change detection,
275
     * while scalars are stored as-is for performance.
276
     *
277
     * @return $this
278
     */
279
    public function syncOriginal()
280
    {
281
        $this->original     = [];
157✔
282
        $this->_onlyScalars = true;
157✔
283

284
        foreach ($this->attributes as $key => $value) {
157✔
285
            if (is_object($value) || is_array($value)) {
132✔
286
                $this->original[$key] = json_encode($this->normalizeValue($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
30✔
287
                $this->_onlyScalars   = false;
30✔
288
            } else {
289
                $this->original[$key] = $value;
128✔
290
            }
291
        }
292

293
        return $this;
157✔
294
    }
295

296
    /**
297
     * Checks a property to see if it has changed since the entity
298
     * was created. Or, without a parameter, checks if any
299
     * properties have changed.
300
     *
301
     * @param string|null $key class property
302
     */
303
    public function hasChanged(?string $key = null): bool
304
    {
305
        // If no parameter was given then check all attributes
306
        if ($key === null) {
44✔
307
            if ($this->_onlyScalars) {
6✔
308
                return $this->original !== $this->attributes;
5✔
309
            }
310

311
            foreach (array_keys($this->attributes) as $attributeKey) {
1✔
312
                if ($this->hasChanged($attributeKey)) {
1✔
313
                    return true;
1✔
314
                }
315
            }
316

317
            return false;
1✔
318
        }
319

320
        $dbColumn = $this->mapProperty($key);
40✔
321

322
        // Key doesn't exist in either
323
        if (! array_key_exists($dbColumn, $this->original) && ! array_key_exists($dbColumn, $this->attributes)) {
40✔
324
            return false;
1✔
325
        }
326

327
        // It's a new element
328
        if (! array_key_exists($dbColumn, $this->original) && array_key_exists($dbColumn, $this->attributes)) {
39✔
329
            return true;
4✔
330
        }
331

332
        // It was removed
333
        if (array_key_exists($dbColumn, $this->original) && ! array_key_exists($dbColumn, $this->attributes)) {
37✔
334
            return true;
1✔
335
        }
336

337
        $originalValue = $this->original[$dbColumn];
36✔
338
        $currentValue  = $this->attributes[$dbColumn];
36✔
339

340
        // If original is a string, it was JSON-encoded (object or array)
341
        if (is_string($originalValue) && (is_object($currentValue) || is_array($currentValue))) {
36✔
342
            return $originalValue !== json_encode($this->normalizeValue($currentValue), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
21✔
343
        }
344

345
        // For scalars, use direct comparison
346
        return $originalValue !== $currentValue;
18✔
347
    }
348

349
    /**
350
     * Recursively normalize a value for comparison.
351
     * Converts objects and arrays to a JSON-encodable format.
352
     */
353
    private function normalizeValue(mixed $data): mixed
354
    {
355
        if (is_array($data)) {
30✔
356
            $normalized = [];
24✔
357

358
            foreach ($data as $key => $value) {
24✔
359
                $normalized[$key] = $this->normalizeValue($value);
22✔
360
            }
361

362
            return $normalized;
24✔
363
        }
364

365
        if (is_object($data)) {
29✔
366
            // Check for Entity instance (use raw values, recursive)
367
            if ($data instanceof Entity) {
24✔
368
                $objectData = $data->toRawArray(false, true);
2✔
369
            } elseif ($data instanceof JsonSerializable) {
22✔
370
                $objectData = $data->jsonSerialize();
1✔
371
            } elseif (method_exists($data, 'toArray')) {
21✔
372
                $objectData = $data->toArray();
1✔
373
            } elseif ($data instanceof UnitEnum) {
20✔
374
                return [
5✔
375
                    '__class' => $data::class,
5✔
376
                    '__enum'  => $data instanceof BackedEnum ? $data->value : $data->name,
5✔
377
                ];
5✔
378
            } elseif ($data instanceof DateTimeInterface) {
15✔
379
                return [
8✔
380
                    '__class'    => $data::class,
8✔
381
                    '__datetime' => $data->format(DATE_ATOM),
8✔
382
                ];
8✔
383
            } else {
384
                $objectData = get_object_vars($data);
7✔
385
            }
386

387
            return [
11✔
388
                '__class' => $data::class,
11✔
389
                '__data'  => $this->normalizeValue($objectData),
11✔
390
            ];
11✔
391
        }
392

393
        // Return scalars and null as-is
394
        return $data;
22✔
395
    }
396

397
    /**
398
     * Set raw data array without any mutations
399
     *
400
     * @return $this
401
     */
402
    public function injectRawData(array $data)
403
    {
404
        $this->attributes = $data;
26✔
405

406
        $this->syncOriginal();
26✔
407

408
        return $this;
26✔
409
    }
410

411
    /**
412
     * Set raw data array without any mutations
413
     *
414
     * @return $this
415
     *
416
     * @deprecated Use injectRawData() instead.
417
     */
418
    public function setAttributes(array $data)
419
    {
420
        return $this->injectRawData($data);
7✔
421
    }
422

423
    /**
424
     * Checks the datamap to see if this property name is being mapped,
425
     * and returns the db column name, if any, or the original property name.
426
     *
427
     * @return string db column name
428
     */
429
    protected function mapProperty(string $key)
430
    {
431
        if ($this->datamap === []) {
145✔
432
            return $key;
108✔
433
        }
434

435
        if (! empty($this->datamap[$key])) {
37✔
436
            return $this->datamap[$key];
17✔
437
        }
438

439
        return $key;
30✔
440
    }
441

442
    /**
443
     * Converts the given string|timestamp|DateTime|Time instance
444
     * into the "CodeIgniter\I18n\Time" object.
445
     *
446
     * @param DateTime|float|int|string|Time $value
447
     *
448
     * @return Time
449
     *
450
     * @throws Exception
451
     */
452
    protected function mutateDate($value)
453
    {
454
        return DatetimeCast::get($value);
32✔
455
    }
456

457
    /**
458
     * Provides the ability to cast an item as a specific data type.
459
     * Add ? at the beginning of the type (i.e. ?string) to get `null`
460
     * instead of casting $value when $value is null.
461
     *
462
     * @param bool|float|int|string|null $value     Attribute value
463
     * @param string                     $attribute Attribute name
464
     * @param string                     $method    Allowed to "get" and "set"
465
     *
466
     * @return array|bool|float|int|object|string|null
467
     *
468
     * @throws CastException
469
     */
470
    protected function castAs($value, string $attribute, string $method = 'get')
471
    {
472
        return $this->dataCaster
139✔
473
            // @TODO if $casts is readonly, we don't need the setTypes() method.
139✔
474
            ->setTypes($this->casts)
139✔
475
            ->castAs($value, $attribute, $method);
139✔
476
    }
477

478
    /**
479
     * Support for json_encode()
480
     *
481
     * @return array
482
     */
483
    #[ReturnTypeWillChange]
484
    public function jsonSerialize()
485
    {
486
        return $this->toArray();
1✔
487
    }
488

489
    /**
490
     * Change the value of the private $_cast property
491
     *
492
     * @return bool|Entity
493
     */
494
    public function cast(?bool $cast = null)
495
    {
496
        if ($cast === null) {
11✔
497
            return $this->_cast;
10✔
498
        }
499

500
        $this->_cast = $cast;
10✔
501

502
        return $this;
10✔
503
    }
504

505
    /**
506
     * Magic method to all protected/private class properties to be
507
     * easily set, either through a direct access or a
508
     * `setCamelCasedProperty()` method.
509
     *
510
     * Examples:
511
     *  $this->my_property = $p;
512
     *  $this->setMyProperty() = $p;
513
     *
514
     * @param array|bool|float|int|object|string|null $value
515
     *
516
     * @return void
517
     *
518
     * @throws Exception
519
     */
520
    public function __set(string $key, $value = null)
521
    {
522
        $dbColumn = $this->mapProperty($key);
119✔
523

524
        // Check if the field should be mutated into a date
525
        if (in_array($dbColumn, $this->dates, true)) {
119✔
526
            $value = $this->mutateDate($value);
17✔
527
        }
528

529
        $value = $this->castAs($value, $dbColumn, 'set');
119✔
530

531
        // if a setter method exists for this key, use that method to
532
        // insert this value. should be outside $isNullable check,
533
        // so maybe wants to do sth with null value automatically
534
        $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn)));
111✔
535

536
        // If a "`_set` + $key" method exists, it is a setter.
537
        if (method_exists($this, '_' . $method)) {
111✔
538
            $this->{'_' . $method}($value);
1✔
539

540
            return;
1✔
541
        }
542

543
        // If a "`set` + $key" method exists, it is also a setter.
544
        if (method_exists($this, $method) && $method !== 'setAttributes') {
110✔
545
            $this->{$method}($value);
12✔
546

547
            return;
12✔
548
        }
549

550
        // Otherwise, just the value. This allows for creation of new
551
        // class properties that are undefined, though they cannot be
552
        // saved. Useful for grabbing values through joins, assigning
553
        // relationships, etc.
554
        $this->attributes[$dbColumn] = $value;
101✔
555
    }
556

557
    /**
558
     * Magic method to allow retrieval of protected and private class properties
559
     * either by their name, or through a `getCamelCasedProperty()` method.
560
     *
561
     * Examples:
562
     *  $p = $this->my_property
563
     *  $p = $this->getMyProperty()
564
     *
565
     * @return array|bool|float|int|object|string|null
566
     *
567
     * @throws Exception
568
     *
569
     * @params string $key class property
570
     */
571
    public function __get(string $key)
572
    {
573
        $dbColumn = $this->mapProperty($key);
88✔
574

575
        $result = null;
88✔
576

577
        // Convert to CamelCase for the method
578
        $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn)));
88✔
579

580
        // if a getter method exists for this key,
581
        // use that method to insert this value.
582
        if (method_exists($this, '_' . $method)) {
88✔
583
            // If a "`_get` + $key" method exists, it is a getter.
584
            $result = $this->{'_' . $method}();
1✔
585
        } elseif (method_exists($this, $method)) {
87✔
586
            // If a "`get` + $key" method exists, it is also a getter.
587
            $result = $this->{$method}();
9✔
588
        }
589

590
        // Otherwise return the protected property
591
        // if it exists.
592
        elseif (array_key_exists($dbColumn, $this->attributes)) {
84✔
593
            $result = $this->attributes[$dbColumn];
82✔
594
        }
595

596
        // Do we need to mutate this into a date?
597
        if (in_array($dbColumn, $this->dates, true)) {
88✔
598
            $result = $this->mutateDate($result);
18✔
599
        }
600
        // Or cast it as something?
601
        elseif ($this->_cast) {
82✔
602
            $result = $this->castAs($result, $dbColumn);
82✔
603
        }
604

605
        return $result;
83✔
606
    }
607

608
    /**
609
     * Returns true if a property exists names $key, or a getter method
610
     * exists named like for __get().
611
     */
612
    public function __isset(string $key): bool
613
    {
614
        if ($this->isMappedDbColumn($key)) {
6✔
615
            return false;
2✔
616
        }
617

618
        $dbColumn = $this->mapProperty($key);
6✔
619

620
        $method = 'get' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn)));
6✔
621

622
        if (method_exists($this, $method)) {
6✔
623
            return true;
1✔
624
        }
625

626
        return isset($this->attributes[$dbColumn]);
6✔
627
    }
628

629
    /**
630
     * Unsets an attribute property.
631
     */
632
    public function __unset(string $key): void
633
    {
634
        if ($this->isMappedDbColumn($key)) {
4✔
635
            return;
1✔
636
        }
637

638
        $dbColumn = $this->mapProperty($key);
4✔
639

640
        unset($this->attributes[$dbColumn]);
4✔
641
    }
642

643
    /**
644
     * Whether this key is mapped db column name?
645
     */
646
    protected function isMappedDbColumn(string $key): bool
647
    {
648
        $dbColumn = $this->mapProperty($key);
8✔
649

650
        // The $key is a property name which has mapped db column name
651
        if ($key !== $dbColumn) {
8✔
652
            return false;
5✔
653
        }
654

655
        return $this->hasMappedProperty($key);
6✔
656
    }
657

658
    /**
659
     * Whether this key has mapped property?
660
     */
661
    protected function hasMappedProperty(string $key): bool
662
    {
663
        $property = array_search($key, $this->datamap, true);
6✔
664

665
        return $property !== false;
6✔
666
    }
667
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc