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

codeigniter4 / CodeIgniter4 / 18965455819

31 Oct 2025 07:10AM UTC coverage: 84.527% (+0.02%) from 84.503%
18965455819

Pull #9779

github

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

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

4 existing lines in 1 file now uncovered.

21491 of 25425 relevant lines covered (84.53%)

196.24 hits per line

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

96.34
/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 Exception;
35
use JsonSerializable;
36
use ReturnTypeWillChange;
37
use UnitEnum;
38

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

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

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

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

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

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

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

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

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

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

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

153
        $this->syncOriginal();
156✔
154

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

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

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

177
        return $this;
18✔
178
    }
179

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

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

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

201
        $return = [];
10✔
202

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

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

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

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

222
        return $return;
10✔
223
    }
224

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

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

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

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

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

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

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

267
        return $return;
11✔
268
    }
269

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

283
        foreach ($this->attributes as $key => $value) {
156✔
284
            if (is_object($value) || is_array($value)) {
131✔
285
                $this->original[$key] = json_encode($this->normalizeValue($value), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
29✔
286
                $this->_onlyScalars   = false;
29✔
287
            } else {
288
                $this->original[$key] = $value;
127✔
289
            }
290
        }
291

292
        return $this;
156✔
293
    }
294

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

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

316
            return false;
1✔
317
        }
318

319
        $dbColumn = $this->mapProperty($key);
39✔
320

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

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

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

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

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

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

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

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

361
            return $normalized;
24✔
362
        }
363

364
        if (is_object($data)) {
28✔
365
            // Check for Entity instance (use raw values, recursive)
366
            if ($data instanceof Entity) {
23✔
367
                $objectData = $data->toRawArray(false, true);
2✔
368
            } elseif ($data instanceof JsonSerializable) {
21✔
369
                $objectData = $data->jsonSerialize();
1✔
370
            } elseif (method_exists($data, 'toArray')) {
20✔
371
                $objectData = $data->toArray();
1✔
372
            } elseif ($data instanceof UnitEnum) {
19✔
373
                return [
5✔
374
                    '__class' => $data::class,
5✔
375
                    '__enum'  => $data instanceof BackedEnum ? $data->value : $data->name,
5✔
376
                ];
5✔
377
            } else {
378
                $objectData = get_object_vars($data);
14✔
379
            }
380

381
            return [
18✔
382
                '__class' => $data::class,
18✔
383
                '__data'  => $this->normalizeValue($objectData),
18✔
384
            ];
18✔
385
        }
386

387
        // Return scalars and null as-is
388
        return $data;
22✔
389
    }
390

391
    /**
392
     * Set raw data array without any mutations
393
     *
394
     * @return $this
395
     */
396
    public function injectRawData(array $data)
397
    {
398
        $this->attributes = $data;
26✔
399

400
        $this->syncOriginal();
26✔
401

402
        return $this;
26✔
403
    }
404

405
    /**
406
     * Set raw data array without any mutations
407
     *
408
     * @return $this
409
     *
410
     * @deprecated Use injectRawData() instead.
411
     */
412
    public function setAttributes(array $data)
413
    {
414
        return $this->injectRawData($data);
7✔
415
    }
416

417
    /**
418
     * Checks the datamap to see if this property name is being mapped,
419
     * and returns the db column name, if any, or the original property name.
420
     *
421
     * @return string db column name
422
     */
423
    protected function mapProperty(string $key)
424
    {
425
        if ($this->datamap === []) {
144✔
426
            return $key;
107✔
427
        }
428

429
        if (! empty($this->datamap[$key])) {
37✔
430
            return $this->datamap[$key];
17✔
431
        }
432

433
        return $key;
30✔
434
    }
435

436
    /**
437
     * Converts the given string|timestamp|DateTime|Time instance
438
     * into the "CodeIgniter\I18n\Time" object.
439
     *
440
     * @param DateTime|float|int|string|Time $value
441
     *
442
     * @return Time
443
     *
444
     * @throws Exception
445
     */
446
    protected function mutateDate($value)
447
    {
448
        return DatetimeCast::get($value);
31✔
449
    }
450

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

472
    /**
473
     * Support for json_encode()
474
     *
475
     * @return array
476
     */
477
    #[ReturnTypeWillChange]
478
    public function jsonSerialize()
479
    {
480
        return $this->toArray();
1✔
481
    }
482

483
    /**
484
     * Change the value of the private $_cast property
485
     *
486
     * @return bool|Entity
487
     */
488
    public function cast(?bool $cast = null)
489
    {
490
        if ($cast === null) {
11✔
491
            return $this->_cast;
10✔
492
        }
493

494
        $this->_cast = $cast;
10✔
495

496
        return $this;
10✔
497
    }
498

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

518
        // Check if the field should be mutated into a date
519
        if (in_array($dbColumn, $this->dates, true)) {
118✔
520
            $value = $this->mutateDate($value);
16✔
521
        }
522

523
        $value = $this->castAs($value, $dbColumn, 'set');
118✔
524

525
        // if a setter method exists for this key, use that method to
526
        // insert this value. should be outside $isNullable check,
527
        // so maybe wants to do sth with null value automatically
528
        $method = 'set' . str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $dbColumn)));
110✔
529

530
        // If a "`_set` + $key" method exists, it is a setter.
531
        if (method_exists($this, '_' . $method)) {
110✔
532
            $this->{'_' . $method}($value);
1✔
533

534
            return;
1✔
535
        }
536

537
        // If a "`set` + $key" method exists, it is also a setter.
538
        if (method_exists($this, $method) && $method !== 'setAttributes') {
109✔
539
            $this->{$method}($value);
12✔
540

541
            return;
12✔
542
        }
543

544
        // Otherwise, just the value. This allows for creation of new
545
        // class properties that are undefined, though they cannot be
546
        // saved. Useful for grabbing values through joins, assigning
547
        // relationships, etc.
548
        $this->attributes[$dbColumn] = $value;
100✔
549
    }
550

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

569
        $result = null;
88✔
570

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

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

584
        // Otherwise return the protected property
585
        // if it exists.
586
        elseif (array_key_exists($dbColumn, $this->attributes)) {
84✔
587
            $result = $this->attributes[$dbColumn];
82✔
588
        }
589

590
        // Do we need to mutate this into a date?
591
        if (in_array($dbColumn, $this->dates, true)) {
88✔
592
            $result = $this->mutateDate($result);
18✔
593
        }
594
        // Or cast it as something?
595
        elseif ($this->_cast) {
82✔
596
            $result = $this->castAs($result, $dbColumn);
82✔
597
        }
598

599
        return $result;
83✔
600
    }
601

602
    /**
603
     * Returns true if a property exists names $key, or a getter method
604
     * exists named like for __get().
605
     */
606
    public function __isset(string $key): bool
607
    {
608
        if ($this->isMappedDbColumn($key)) {
6✔
609
            return false;
2✔
610
        }
611

612
        $dbColumn = $this->mapProperty($key);
6✔
613

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

616
        if (method_exists($this, $method)) {
6✔
617
            return true;
1✔
618
        }
619

620
        return isset($this->attributes[$dbColumn]);
6✔
621
    }
622

623
    /**
624
     * Unsets an attribute property.
625
     */
626
    public function __unset(string $key): void
627
    {
628
        if ($this->isMappedDbColumn($key)) {
4✔
629
            return;
1✔
630
        }
631

632
        $dbColumn = $this->mapProperty($key);
4✔
633

634
        unset($this->attributes[$dbColumn]);
4✔
635
    }
636

637
    /**
638
     * Whether this key is mapped db column name?
639
     */
640
    protected function isMappedDbColumn(string $key): bool
641
    {
642
        $dbColumn = $this->mapProperty($key);
8✔
643

644
        // The $key is a property name which has mapped db column name
645
        if ($key !== $dbColumn) {
8✔
646
            return false;
5✔
647
        }
648

649
        return $this->hasMappedProperty($key);
6✔
650
    }
651

652
    /**
653
     * Whether this key has mapped property?
654
     */
655
    protected function hasMappedProperty(string $key): bool
656
    {
657
        $property = array_search($key, $this->datamap, true);
6✔
658

659
        return $property !== false;
6✔
660
    }
661
}
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