diff --git a/framework/core/src/Database/AbstractModel.php b/framework/core/src/Database/AbstractModel.php index f9ed974506..980bc0e0aa 100644 --- a/framework/core/src/Database/AbstractModel.php +++ b/framework/core/src/Database/AbstractModel.php @@ -54,6 +54,18 @@ abstract class AbstractModel extends Eloquent */ public static array $defaults = []; + /** + * Resolved casts, keyed by model class. + * + * Eloquent asks for the casts on every attribute access, so resolving the + * class hierarchy each time is measurable: an index request resolved them + * over 130,000 times. The result only changes when an extender registers + * new casts, which is what flushCastsCache() is for. + * + * @var array> + */ + protected static array $castsCache = []; + /** * An alias for the table name, used in queries. * @@ -102,13 +114,27 @@ public function __construct(array $attributes = []) public function getCasts(): array { + if (isset(static::$castsCache[static::class])) { + return static::$castsCache[static::class]; + } + $casts = parent::getCasts(); foreach (array_merge(array_reverse(class_parents($this)), [static::class]) as $class) { $casts = array_merge($casts, Arr::get(static::$customCasts, $class, [])); } - return $casts; + return static::$castsCache[static::class] = $casts; + } + + /** + * Discard the resolved casts, so that newly registered ones are picked up. + * + * @internal + */ + public static function flushCastsCache(): void + { + static::$castsCache = []; } /** diff --git a/framework/core/src/Extend/Model.php b/framework/core/src/Extend/Model.php index 087872c85c..60e2564235 100644 --- a/framework/core/src/Extend/Model.php +++ b/framework/core/src/Extend/Model.php @@ -181,5 +181,8 @@ public function extend(Container $container, ?Extension $extension = null): void $this->casts ) ); + + // Models may already have resolved (and memoised) their casts by now. + AbstractModel::flushCastsCache(); } } diff --git a/framework/core/tests/unit/Database/AbstractModelCastsTest.php b/framework/core/tests/unit/Database/AbstractModelCastsTest.php new file mode 100644 index 0000000000..f139174db4 --- /dev/null +++ b/framework/core/tests/unit/Database/AbstractModelCastsTest.php @@ -0,0 +1,118 @@ +assertSame('bool', $model->getCasts()['is_native'] ?? null); + } + + public function test_it_includes_custom_casts_registered_for_the_model(): void + { + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['extended' => 'datetime']; + + $model = new AbstractModelCastsTestModel; + + $this->assertSame('datetime', $model->getCasts()['extended'] ?? null); + } + + public function test_it_inherits_custom_casts_registered_against_a_parent_class(): void + { + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['from_parent' => 'int']; + + $model = new AbstractModelCastsTestChildModel; + + $this->assertSame('int', $model->getCasts()['from_parent'] ?? null); + } + + public function test_casts_registered_on_a_child_override_the_parent(): void + { + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['overridden' => 'int']; + AbstractModel::$customCasts[AbstractModelCastsTestChildModel::class] = ['overridden' => 'bool']; + + $model = new AbstractModelCastsTestChildModel; + + $this->assertSame('bool', $model->getCasts()['overridden'] ?? null); + } + + public function test_two_instances_of_the_same_model_resolve_the_same_casts(): void + { + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['shared' => 'bool']; + + $this->assertEquals( + (new AbstractModelCastsTestModel)->getCasts(), + (new AbstractModelCastsTestModel)->getCasts() + ); + } + + public function test_sibling_models_do_not_share_each_others_casts(): void + { + // A per-class cache keyed carelessly (or not keyed at all) would leak + // the first model's casts onto the second. + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['only_on_parent' => 'bool']; + AbstractModel::$customCasts[AbstractModelCastsTestSiblingModel::class] = ['only_on_sibling' => 'int']; + + (new AbstractModelCastsTestModel)->getCasts(); + + $sibling = (new AbstractModelCastsTestSiblingModel)->getCasts(); + + $this->assertArrayNotHasKey('only_on_parent', $sibling); + $this->assertSame('int', $sibling['only_on_sibling'] ?? null); + } + + public function test_casts_registered_after_a_first_resolution_are_picked_up(): void + { + // Extenders mutate $customCasts during boot, which can happen after a + // model has already resolved its casts once. A cache that never + // invalidates would silently ignore the newly registered cast. + $this->assertArrayNotHasKey('late', (new AbstractModelCastsTestModel)->getCasts()); + + AbstractModel::$customCasts[AbstractModelCastsTestModel::class] = ['late' => 'bool']; + AbstractModel::flushCastsCache(); + + $this->assertSame('bool', (new AbstractModelCastsTestModel)->getCasts()['late'] ?? null); + } +} + +class AbstractModelCastsTestModel extends AbstractModel +{ + protected $casts = ['is_native' => 'bool']; +} + +class AbstractModelCastsTestChildModel extends AbstractModelCastsTestModel +{ +} + +class AbstractModelCastsTestSiblingModel extends AbstractModel +{ +}