diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index 9e3e4609e..3ca36bb7f 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -103,6 +103,14 @@ trait HasAttributes */ protected ?array $mergedCastsCache = null; + /** + * The cached results of cast metadata predicate methods for this instance. + * + * Keyed by predicate name then attribute name. Cleared whenever cast state + * mutates via flushCastCaches(). + */ + protected array $castMetadataCache = []; + /** * The built-in, primitive cast types supported by Eloquent. * @@ -199,7 +207,7 @@ protected function initializeHasAttributes(): void array_merge($this->casts, $this->casts()), ); - $this->mergedCastsCache = null; + $this->flushCastCaches(); } /** @@ -718,11 +726,20 @@ public function mergeCasts(array $casts): static $this->casts = array_merge($this->casts, $casts); - $this->mergedCastsCache = null; + $this->flushCastCaches(); return $this; } + /** + * Flush the per-instance cast metadata caches. + */ + protected function flushCastCaches(): void + { + $this->mergedCastsCache = null; + $this->castMetadataCache = []; + } + /** * Ensure that the given casts are strings. */ @@ -873,10 +890,14 @@ protected function getEnumCastableAttributeValue(string $key, mixed $value): mix */ protected function getCastType(string $key): string { + if (isset($this->castMetadataCache['castType'][$key])) { + return $this->castMetadataCache['castType'][$key]; + } + $castType = $this->getCasts()[$key]; if (isset(static::$castTypeCache[$castType])) { - return static::$castTypeCache[$castType]; + return $this->castMetadataCache['castType'][$key] = static::$castTypeCache[$castType]; } if ($this->isCustomDateTimeCast($castType)) { @@ -891,7 +912,7 @@ protected function getCastType(string $key): string $convertedCastType = trim(strtolower($castType)); } - return static::$castTypeCache[$castType] = $convertedCastType; + return $this->castMetadataCache['castType'][$key] = static::$castTypeCache[$castType] = $convertedCastType; } /** @@ -1533,7 +1554,7 @@ protected function casts(): array */ protected function isDateCastable(string $key): bool { - return $this->hasCast($key, ['date', 'datetime', 'immutable_date', 'immutable_datetime']); + return $this->castMetadataCache['dateCastable'][$key] ??= $this->hasCast($key, ['date', 'datetime', 'immutable_date', 'immutable_datetime']); } /** @@ -1541,7 +1562,7 @@ protected function isDateCastable(string $key): bool */ protected function isDateCastableWithCustomFormat(string $key): bool { - return $this->hasCast($key, ['custom_datetime', 'immutable_custom_datetime']); + return $this->castMetadataCache['dateCastableWithCustomFormat'][$key] ??= $this->hasCast($key, ['custom_datetime', 'immutable_custom_datetime']); } /** @@ -1549,7 +1570,7 @@ protected function isDateCastableWithCustomFormat(string $key): bool */ protected function isJsonCastable(string $key): bool { - return $this->hasCast($key, ['array', 'json', 'json:unicode', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + return $this->castMetadataCache['jsonCastable'][$key] ??= $this->hasCast($key, ['array', 'json', 'json:unicode', 'object', 'collection', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); } /** @@ -1557,7 +1578,7 @@ protected function isJsonCastable(string $key): bool */ protected function isEncryptedCastable(string $key): bool { - return $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); + return $this->castMetadataCache['encryptedCastable'][$key] ??= $this->hasCast($key, ['encrypted', 'encrypted:array', 'encrypted:collection', 'encrypted:json', 'encrypted:object']); } /** @@ -1567,20 +1588,24 @@ protected function isEncryptedCastable(string $key): bool */ protected function isClassCastable(string $key): bool { + if (isset($this->castMetadataCache['classCastable'][$key])) { + return $this->castMetadataCache['classCastable'][$key]; + } + $casts = $this->getCasts(); if (! array_key_exists($key, $casts)) { - return false; + return $this->castMetadataCache['classCastable'][$key] = false; } $castType = $this->parseCasterClass($casts[$key]); if (in_array($castType, static::$primitiveCastTypes)) { - return false; + return $this->castMetadataCache['classCastable'][$key] = false; } if (class_exists($castType)) { - return true; + return $this->castMetadataCache['classCastable'][$key] = true; } throw new InvalidCastException($this, $key, $castType); @@ -1591,23 +1616,27 @@ protected function isClassCastable(string $key): bool */ protected function isEnumCastable(string $key): bool { + if (isset($this->castMetadataCache['enumCastable'][$key])) { + return $this->castMetadataCache['enumCastable'][$key]; + } + $casts = $this->getCasts(); if (! array_key_exists($key, $casts)) { - return false; + return $this->castMetadataCache['enumCastable'][$key] = false; } $castType = $casts[$key]; if (in_array($castType, static::$primitiveCastTypes)) { - return false; + return $this->castMetadataCache['enumCastable'][$key] = false; } if (is_subclass_of($castType, Castable::class)) { - return false; + return $this->castMetadataCache['enumCastable'][$key] = false; } - return enum_exists($castType); + return $this->castMetadataCache['enumCastable'][$key] = enum_exists($castType); } /** @@ -1617,13 +1646,17 @@ protected function isEnumCastable(string $key): bool */ protected function isClassDeviable(string $key): bool { + if (isset($this->castMetadataCache['classDeviable'][$key])) { + return $this->castMetadataCache['classDeviable'][$key]; + } + if (! $this->isClassCastable($key)) { - return false; + return $this->castMetadataCache['classDeviable'][$key] = false; } $castType = $this->resolveCasterClass($key); - return method_exists($castType::class, 'increment') && method_exists($castType::class, 'decrement'); + return $this->castMetadataCache['classDeviable'][$key] = method_exists($castType::class, 'increment') && method_exists($castType::class, 'decrement'); } /** @@ -1633,7 +1666,7 @@ protected function isClassDeviable(string $key): bool */ protected function isClassSerializable(string $key): bool { - return ! $this->isEnumCastable($key) + return $this->castMetadataCache['classSerializable'][$key] ??= ! $this->isEnumCastable($key) && $this->isClassCastable($key) && method_exists($this->resolveCasterClass($key), 'serialize'); } @@ -1643,7 +1676,7 @@ protected function isClassSerializable(string $key): bool */ protected function isClassComparable(string $key): bool { - return ! $this->isEnumCastable($key) + return $this->castMetadataCache['classComparable'][$key] ??= ! $this->isEnumCastable($key) && $this->isClassCastable($key) && method_exists($this->resolveCasterClass($key), 'compare'); } diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 61a12a47a..509d21808 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1865,7 +1865,7 @@ public function setKeyName(string $key): static { $this->primaryKey = $key; - $this->mergedCastsCache = null; + $this->flushCastCaches(); return $this; } @@ -1893,7 +1893,7 @@ public function setKeyType(string $type): static { $this->keyType = $type; - $this->mergedCastsCache = null; + $this->flushCastCaches(); return $this; } @@ -1913,7 +1913,7 @@ public function setIncrementing(bool $value): static { $this->incrementing = $value; - $this->mergedCastsCache = null; + $this->flushCastCaches(); return $this; } @@ -2314,7 +2314,7 @@ public function __sleep(): array $this->classCastCache = []; $this->attributeCastCache = []; - $this->mergedCastsCache = null; + $this->flushCastCaches(); $this->relationAutoloadCallback = null; $this->relationAutoloadContext = null; diff --git a/src/database/src/Eloquent/SoftDeletes.php b/src/database/src/Eloquent/SoftDeletes.php index 8e8cfdaf5..e94b87812 100644 --- a/src/database/src/Eloquent/SoftDeletes.php +++ b/src/database/src/Eloquent/SoftDeletes.php @@ -38,7 +38,7 @@ public function initializeSoftDeletes(): void $this->casts[$this->getDeletedAtColumn()] = 'datetime'; } - $this->mergedCastsCache = null; + $this->flushCastCaches(); } /** diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index dd058e932..9f2a640e0 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -38,6 +38,7 @@ use Hypervel\Database\Eloquent\Concerns\HasUuids; use Hypervel\Database\Eloquent\Factories\Factory; use Hypervel\Database\Eloquent\Factories\HasFactory; +use Hypervel\Database\Eloquent\InvalidCastException; use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\MassAssignmentException; use Hypervel\Database\Eloquent\MissingAttributeException; @@ -50,6 +51,7 @@ use Hypervel\Database\Query\Processors\Processor; use Hypervel\Events\Dispatcher as EventDispatcher; use Hypervel\Support\Carbon; +use Hypervel\Support\ClassInvoker; use Hypervel\Support\Collection as BaseCollection; use Hypervel\Support\Fluent; use Hypervel\Support\HtmlString; @@ -3051,6 +3053,169 @@ function (GetCastsSoftDeletingBootingStub $model) { } } + public function testCastCacheIsInvalidatedByMergeCasts() + { + $model = new ModelStub; + $model->mergeCasts(['foo' => 'array']); + + $this->assertTrue($model->hasCast('foo', 'array')); + + $model->mergeCasts(['foo' => 'integer']); + + $this->assertFalse($model->hasCast('foo', 'array')); + } + + public function testCastCacheIsInvalidatedBySetKeyType() + { + $model = new ModelStub; + + $this->assertTrue($model->hasCast($model->getKeyName(), 'int')); + + $model->setKeyType('string'); + + $this->assertFalse($model->hasCast($model->getKeyName(), 'int')); + $this->assertTrue($model->hasCast($model->getKeyName(), 'string')); + } + + public function testCastCacheIsInvalidatedBySetKeyName() + { + $model = new ModelStub; + $model->hasCast('id', 'int'); + + $invoker = new ClassInvoker($model); + $this->assertNotEmpty($invoker->castMetadataCache, 'cache should be populated before mutation'); + + $model->setKeyName('uuid'); + + $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared after setKeyName'); + } + + public function testCastCacheIsInvalidatedBySetIncrementing() + { + $model = new ModelStub; + $model->hasCast('id', 'int'); + + $invoker = new ClassInvoker($model); + $this->assertNotEmpty($invoker->castMetadataCache, 'cache should be populated before mutation'); + + $model->setIncrementing(false); + + $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared after setIncrementing'); + } + + public function testCastCacheIsInvalidatedDuringInitializeHasAttributes() + { + Model::clearBootedModels(); + + Model::setEventDispatcher($dispatcher = new EventDispatcher); + + try { + $dispatcher->listen( + 'eloquent.booting: ' . GetCastsBootingStub::class, + function (GetCastsBootingStub $model) { + $model->hasCast('id', 'int'); + } + ); + + $instance = new GetCastsBootingStub; + + $invoker = new ClassInvoker($instance); + $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared after initializeHasAttributes'); + } finally { + GetCastsBootingStub::flushEventListeners(); + Model::clearBootedModels(); + Model::unsetEventDispatcher(); + } + } + + public function testInitializeSoftDeletesClearsCastMetadataCache() + { + $model = new GetCastsSoftDeletingBootingStub; + $invoker = new ClassInvoker($model); + + $invoker->isDateCastable($model->getDeletedAtColumn()); + $this->assertNotEmpty($invoker->castMetadataCache, 'cache should be populated'); + + $invoker->initializeSoftDeletes(); + + $this->assertSame([], $invoker->castMetadataCache, 'initializeSoftDeletes should clear the cache'); + } + + public function testCastCacheIsClearedDuringSleep() + { + $model = new ModelStub; + $model->hasCast('id', 'int'); + + $invoker = new ClassInvoker($model); + $this->assertNotEmpty($invoker->castMetadataCache, 'cache should be populated'); + + $model->__sleep(); + + $this->assertSame([], $invoker->castMetadataCache, 'cache should be cleared by __sleep'); + } + + public function testIsClassCastableThrowsRepeatedlyForInvalidCast() + { + $invoker = new ClassInvoker(new CastCacheInvalidClassStub); + + try { + $invoker->isClassCastable('foo'); + $this->fail('Expected InvalidCastException on first call'); + } catch (InvalidCastException) { + // expected + } + + $this->expectException(InvalidCastException::class); + $invoker->isClassCastable('foo'); + } + + public function testCastCacheIsIsolatedPerInstance() + { + $a = new ModelStub; + $b = new ModelStub; + + $a->mergeCasts(['castedFloat' => 'integer']); + + // Populate $a's cache first to make any class-level leakage visible. + $this->assertTrue($a->hasCast('castedFloat', 'integer')); + + // $b retains the default 'float' cast for castedFloat. + $this->assertTrue($b->hasCast('castedFloat', 'float')); + $this->assertFalse($b->hasCast('castedFloat', 'integer')); + } + + public function testEachPredicatePopulatesItsExpectedBucket() + { + $cases = [ + ['castType', 'id', fn (ClassInvoker $i) => $i->getCastType('id')], + ['dateCastable', 'foo', fn (ClassInvoker $i) => $i->isDateCastable('foo')], + ['dateCastableWithCustomFormat', 'foo', fn (ClassInvoker $i) => $i->isDateCastableWithCustomFormat('foo')], + ['jsonCastable', 'foo', fn (ClassInvoker $i) => $i->isJsonCastable('foo')], + ['encryptedCastable', 'foo', fn (ClassInvoker $i) => $i->isEncryptedCastable('foo')], + ['classCastable', 'foo', fn (ClassInvoker $i) => $i->isClassCastable('foo')], + ['enumCastable', 'foo', fn (ClassInvoker $i) => $i->isEnumCastable('foo')], + ['classDeviable', 'foo', fn (ClassInvoker $i) => $i->isClassDeviable('foo')], + ['classSerializable', 'foo', fn (ClassInvoker $i) => $i->isClassSerializable('foo')], + ['classComparable', 'foo', fn (ClassInvoker $i) => $i->isClassComparable('foo')], + ]; + + foreach ($cases as [$bucket, $key, $call]) { + $invoker = new ClassInvoker(new ModelStub); + $call($invoker); + + $this->assertArrayHasKey( + $bucket, + $invoker->castMetadataCache, + "Predicate should populate '{$bucket}' bucket" + ); + $this->assertArrayHasKey( + $key, + $invoker->castMetadataCache[$bucket], + "Predicate should write to '{$bucket}'[{$key}], not under any other key" + ); + } + } + public function testMergeCastsMergesCastsUsingArrays() { $model = new CastingStub; @@ -4634,6 +4799,13 @@ class GetCastsSoftDeletingBootingStub extends Model protected array $guarded = []; } +class CastCacheInvalidClassStub extends Model +{ + protected array $guarded = []; + + protected array $casts = ['foo' => '\This\Class\Does\Not\Exist']; +} + enum ConnectionName { case Foo; diff --git a/tests/Database/DatabaseEloquentWithCastsTest.php b/tests/Database/DatabaseEloquentWithCastsTest.php index bece5b3a3..9972adeda 100644 --- a/tests/Database/DatabaseEloquentWithCastsTest.php +++ b/tests/Database/DatabaseEloquentWithCastsTest.php @@ -93,6 +93,17 @@ public function testWithCastsDoesNotLeakAcrossQueries() $this->assertInstanceOf(CarbonInterface::class, $default->time); } + public function testWithCastsDoesNotLeakPredicateCacheAcrossQueries() + { + Time::query()->insert(['time' => '07:30']); + + $scoped = Time::query()->withCasts(['time' => 'string'])->first(); + $this->assertFalse($scoped->hasCast('time', 'datetime')); + + $default = Time::query()->first(); + $this->assertTrue($default->hasCast('time', 'datetime')); + } + public function testThrowsExceptionIfCastableAttributeWasNotRetrievedAndPreventMissingAttributesIsEnabled() { Time::create(['time' => now()]);