diff --git a/src/Illuminate/Database/Eloquent/Casts/Attribute.php b/src/Illuminate/Database/Eloquent/Casts/Attribute.php new file mode 100644 index 000000000000..0aa802f537d0 --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Casts/Attribute.php @@ -0,0 +1,55 @@ +get = $get; + $this->set = $set; + } + + /** + * Create a new attribute accessor. + * + * @param callable $get + * @return static + */ + public static function get(callable $get) + { + return new static($get); + } + + /** + * Create a new attribute mutator. + * + * @param callable $set + * @return static + */ + public static function set(callable $set) + { + return new static(null, $set); + } +} diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 2ac2a8e7dfa2..8daf2d95c7ba 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -10,6 +10,7 @@ use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Eloquent\Casts\AsArrayObject; use Illuminate\Database\Eloquent\Casts\AsCollection; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\InvalidCastException; use Illuminate\Database\Eloquent\JsonEncodingException; use Illuminate\Database\Eloquent\Relations\Relation; @@ -22,6 +23,9 @@ use Illuminate\Support\Str; use InvalidArgumentException; use LogicException; +use ReflectionClass; +use ReflectionMethod; +use ReflectionNamedType; trait HasAttributes { @@ -60,6 +64,13 @@ trait HasAttributes */ protected $classCastCache = []; + /** + * The attributes that have been cast using "Attribute" return type mutators. + * + * @var array + */ + protected $attributeCastCache = []; + /** * The built-in, primitive cast types supported by Eloquent. * @@ -130,6 +141,20 @@ trait HasAttributes */ protected static $mutatorCache = []; + /** + * The cache of the "Attribute" return type marked mutated attributes for each class. + * + * @var array + */ + protected static $attributeMutatorCache = []; + + /** + * The cache of the "Attribute" return type marked mutated, settable attributes for each class. + * + * @var array + */ + protected static $setAttributeMutatorCache = []; + /** * The encrypter instance that is used to encrypt attributes. * @@ -393,6 +418,7 @@ public function getAttribute($key) if (array_key_exists($key, $this->attributes) || array_key_exists($key, $this->casts) || $this->hasGetMutator($key) || + $this->hasAttributeGetMutator($key) || $this->isClassCastable($key)) { return $this->getAttributeValue($key); } @@ -525,6 +551,30 @@ public function hasGetMutator($key) return method_exists($this, 'get'.Str::studly($key).'Attribute'); } + /** + * Determine if a "Attribute" return type marked get mutator exists for an attribute. + * + * @param string $key + * @return bool + */ + public function hasAttributeGetMutator($key) + { + if (isset(static::$attributeMutatorCache[get_class($this)][$key])) { + return static::$attributeMutatorCache[get_class($this)][$key]; + } + + if (! method_exists($this, $method = Str::camel($key))) { + return static::$attributeMutatorCache[get_class($this)][$key] = false; + } + + $returnType = (new ReflectionMethod($this, $method))->getReturnType(); + + return static::$attributeMutatorCache[get_class($this)][$key] = $returnType && + $returnType instanceof ReflectionNamedType && + $returnType->getName() === Attribute::class && + is_callable($this->{$method}()->get); + } + /** * Get the value of an attribute using its mutator. * @@ -537,6 +587,32 @@ protected function mutateAttribute($key, $value) return $this->{'get'.Str::studly($key).'Attribute'}($value); } + /** + * Get the value of an "Attribute" return type marked attribute using its mutator. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function mutateAttributeMarkedAttribute($key, $value) + { + if (isset($this->attributeCastCache[$key])) { + return $this->attributeCastCache[$key]; + } + + $value = call_user_func($this->{Str::camel($key)}()->get ?: function ($value) { + return $value; + }, $value, $this->attributes); + + if (! is_object($value)) { + unset($this->attributeCastCache[$key]); + } else { + $this->attributeCastCache[$key] = $value; + } + + return $value; + } + /** * Get the value of an attribute using its mutator for array conversion. * @@ -546,9 +622,18 @@ protected function mutateAttribute($key, $value) */ protected function mutateAttributeForArray($key, $value) { - $value = $this->isClassCastable($key) - ? $this->getClassCastableAttributeValue($key, $value) - : $this->mutateAttribute($key, $value); + if ($this->isClassCastable($key)) { + $value = $this->getClassCastableAttributeValue($key, $value); + } elseif (isset(static::$attributeMutatorCache[get_class($this)][$key]) && + static::$attributeMutatorCache[get_class($this)][$key] === true) { + $value = $this->mutateAttributeMarkedAttribute($key, $value); + + $value = $value instanceof DateTimeInterface + ? $this->serializeDate($value) + : $value; + } else { + $value = $this->mutateAttribute($key, $value); + } return $value instanceof Arrayable ? $value->toArray() : $value; } @@ -788,6 +873,8 @@ public function setAttribute($key, $value) // this model, such as "json_encoding" a listing of data for storage. if ($this->hasSetMutator($key)) { return $this->setMutatedAttributeValue($key, $value); + } elseif ($this->hasAttributeSetMutator($key)) { + return $this->setAttributeMarkedMutatedAttributeValue($key, $value); } // If an attribute is listed as a "date", we'll convert it from a DateTime @@ -840,6 +927,32 @@ public function hasSetMutator($key) return method_exists($this, 'set'.Str::studly($key).'Attribute'); } + /** + * Determine if an "Attribute" return type marked set mutator exists for an attribute. + * + * @param string $key + * @return bool + */ + public function hasAttributeSetMutator($key) + { + $class = get_class($this); + + if (isset(static::$setAttributeMutatorCache[$class][$key])) { + return static::$setAttributeMutatorCache[$class][$key]; + } + + if (! method_exists($this, $method = Str::camel($key))) { + return static::$setAttributeMutatorCache[$class][$key] = false; + } + + $returnType = (new ReflectionMethod($this, $method))->getReturnType(); + + return static::$setAttributeMutatorCache[$class][$key] = $returnType && + $returnType instanceof ReflectionNamedType && + $returnType->getName() === Attribute::class && + is_callable($this->{$method}()->set); + } + /** * Set the value of an attribute using its mutator. * @@ -852,6 +965,33 @@ protected function setMutatedAttributeValue($key, $value) return $this->{'set'.Str::studly($key).'Attribute'}($value); } + /** + * Set the value of a "Attribute" return type marked attribute using its mutator. + * + * @param string $key + * @param mixed $value + * @return mixed + */ + protected function setAttributeMarkedMutatedAttributeValue($key, $value) + { + $callback = $this->{Str::camel($key)}()->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, call_user_func($callback, $value, $this->attributes) + ) + ); + + if (! is_object($value)) { + unset($this->attributeCastCache[$key]); + } else { + $this->attributeCastCache[$key] = $value; + } + } + /** * Determine if the given attribute is a date or date castable. * @@ -1434,6 +1574,17 @@ protected function parseCasterClass($class) : explode(':', $class, 2)[0]; } + /** + * Merge the cast class and attribute cast attributes back into the model. + * + * @return void + */ + protected function mergeAttributesFromCachedCasts() + { + $this->mergeAttributesFromClassCasts(); + $this->mergeAttributesFromAttributeCasts(); + } + /** * Merge the cast class attributes back into the model. * @@ -1453,6 +1604,27 @@ protected function mergeAttributesFromClassCasts() } } + /** + * Merge the cast class attributes back into the model. + * + * @return void + */ + protected function mergeAttributesFromAttributeCasts() + { + foreach ($this->attributeCastCache as $key => $value) { + $callback = $this->{Str::camel($key)}()->set ?: function ($value) use ($key) { + $this->attributes[$key] = $value; + }; + + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse( + $key, call_user_func($callback, $value, $this->attributes) + ) + ); + } + } + /** * Normalize the response from a custom class caster. * @@ -1472,7 +1644,7 @@ protected function normalizeCastClassResponse($key, $value) */ public function getAttributes() { - $this->mergeAttributesFromClassCasts(); + $this->mergeAttributesFromCachedCasts(); return $this->attributes; } @@ -1503,6 +1675,7 @@ public function setRawAttributes(array $attributes, $sync = false) } $this->classCastCache = []; + $this->attributeCastCache = []; return $this; } @@ -1773,6 +1946,8 @@ protected function transformModelValue($key, $value) // retrieval from the model to a form that is more useful for usage. if ($this->hasGetMutator($key)) { return $this->mutateAttribute($key, $value); + } elseif ($this->hasAttributeGetMutator($key)) { + return $this->mutateAttributeMarkedAttribute($key, $value); } // If the attribute exists within the cast array, we will convert it to @@ -1856,9 +2031,17 @@ public function getMutatedAttributes() */ public static function cacheMutatedAttributes($class) { - static::$mutatorCache[$class] = collect(static::getMutatorMethods($class))->map(function ($match) { - return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match); - })->all(); + static::$attributeMutatorCache[$class] = + collect($attributeMutatorMethods = static::getAttributeMarkedMutatorMethods($class)) + ->mapWithKeys(function ($match) { + return [lcfirst(static::$snakeAttributes ? Str::snake($match) : $match) => true]; + })->all(); + + static::$mutatorCache[$class] = collect(static::getMutatorMethods($class)) + ->merge($attributeMutatorMethods) + ->map(function ($match) { + return lcfirst(static::$snakeAttributes ? Str::snake($match) : $match); + })->all(); } /** @@ -1873,4 +2056,31 @@ protected static function getMutatorMethods($class) return $matches[1]; } + + /** + * Get all of the "Attribute" return typed attribute mutator methods. + * + * @param mixed $class + * @return array + */ + protected static function getAttributeMarkedMutatorMethods($class) + { + $instance = is_object($class) ? $class : new $class; + + return collect((new ReflectionClass($instance))->getMethods())->filter(function ($method) use ($instance) { + $returnType = $method->getReturnType(); + + if ($returnType && + $returnType instanceof ReflectionNamedType && + $returnType->getName() === Attribute::class) { + $method->setAccessible(true); + + if (is_callable($method->invoke($instance)->get)) { + return true; + } + } + + return false; + })->map->name->values()->all(); + } } diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index f59724f2f969..676a08ddd1f8 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -968,7 +968,7 @@ public function saveQuietly(array $options = []) */ public function save(array $options = []) { - $this->mergeAttributesFromClassCasts(); + $this->mergeAttributesFromCachedCasts(); $query = $this->newModelQuery(); @@ -1237,7 +1237,7 @@ public static function destroy($ids) */ public function delete() { - $this->mergeAttributesFromClassCasts(); + $this->mergeAttributesFromCachedCasts(); if (is_null($this->getKeyName())) { throw new LogicException('No primary key defined on model.'); @@ -2176,9 +2176,10 @@ public function escapeWhenCastingToString($escape = true) */ public function __sleep() { - $this->mergeAttributesFromClassCasts(); + $this->mergeAttributesFromCachedCasts(); $this->classCastCache = []; + $this->attributeCastCache = []; return array_keys(get_object_vars($this)); } diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php index 75d16302b472..618fd38b366e 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php @@ -23,6 +23,7 @@ public function testModelsAreProperlyMatchedToParents() $model1->shouldReceive('getAttribute')->with('parent_key')->andReturn(1); $model1->shouldReceive('getAttribute')->with('foo')->passthru(); $model1->shouldReceive('hasGetMutator')->andReturn(false); + $model1->shouldReceive('hasAttributeGetMutator')->andReturn(false); $model1->shouldReceive('getCasts')->andReturn([]); $model1->shouldReceive('getRelationValue', 'relationLoaded', 'setRelation', 'isRelation')->passthru(); @@ -30,6 +31,7 @@ public function testModelsAreProperlyMatchedToParents() $model2->shouldReceive('getAttribute')->with('parent_key')->andReturn(2); $model2->shouldReceive('getAttribute')->with('foo')->passthru(); $model2->shouldReceive('hasGetMutator')->andReturn(false); + $model2->shouldReceive('hasAttributeGetMutator')->andReturn(false); $model2->shouldReceive('getCasts')->andReturn([]); $model2->shouldReceive('getRelationValue', 'relationLoaded', 'setRelation', 'isRelation')->passthru(); diff --git a/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php new file mode 100644 index 000000000000..e666bdafc86a --- /dev/null +++ b/tests/Integration/Database/DatabaseEloquentModelAttributeCastingTest.php @@ -0,0 +1,266 @@ +increments('id'); + $table->timestamps(); + }); + } + + public function testBasicCustomCasting() + { + $model = new TestEloquentModelWithAttributeCast; + $model->uppercase = 'taylor'; + + $this->assertSame('TAYLOR', $model->uppercase); + $this->assertSame('TAYLOR', $model->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $model->toArray()['uppercase']); + + $unserializedModel = unserialize(serialize($model)); + + $this->assertSame('TAYLOR', $unserializedModel->uppercase); + $this->assertSame('TAYLOR', $unserializedModel->getAttributes()['uppercase']); + $this->assertSame('TAYLOR', $unserializedModel->toArray()['uppercase']); + + $model->syncOriginal(); + $model->uppercase = 'dries'; + $this->assertSame('TAYLOR', $model->getOriginal('uppercase')); + + $model = new TestEloquentModelWithAttributeCast; + $model->uppercase = 'taylor'; + $model->syncOriginal(); + $model->uppercase = 'dries'; + $model->getOriginal(); + + $this->assertSame('DRIES', $model->uppercase); + + $model = new TestEloquentModelWithAttributeCast; + + $model->address = $address = new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'); + $address->lineOne = '117 Spencer St.'; + $this->assertSame('117 Spencer St.', $model->getAttributes()['address_line_one']); + + $model = new TestEloquentModelWithAttributeCast; + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + $this->assertSame('My Childhood House', $model->address->lineTwo); + + $this->assertSame('110 Kingsbrook St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $model->address->lineOne = '117 Spencer St.'; + + $this->assertFalse(isset($model->toArray()['address'])); + $this->assertSame('117 Spencer St.', $model->toArray()['address_line_one']); + $this->assertSame('My Childhood House', $model->toArray()['address_line_two']); + + $this->assertSame('117 Spencer St.', json_decode($model->toJson(), true)['address_line_one']); + $this->assertSame('My Childhood House', json_decode($model->toJson(), true)['address_line_two']); + + $model->address = null; + + $this->assertNull($model->toArray()['address_line_one']); + $this->assertNull($model->toArray()['address_line_two']); + + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + $model->options = ['foo' => 'bar']; + $model->options = ['foo' => 'bar']; + $this->assertEquals(['foo' => 'bar'], $model->options); + $this->assertEquals(['foo' => 'bar'], $model->options); + + $this->assertSame(json_encode(['foo' => 'bar']), $model->getAttributes()['options']); + + $model = new TestEloquentModelWithAttributeCast(['options' => []]); + $model->syncOriginal(); + $model->options = ['foo' => 'bar']; + $this->assertTrue($model->isDirty('options')); + + $model = new TestEloquentModelWithAttributeCast; + $model->birthday_at = now(); + $this->assertIsString($model->toArray()['birthday_at']); + } + + public function testGetOriginalWithCastValueObjects() + { + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new AttributeCastAddress('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal('address')->lineOne); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = new AttributeCastAddress('117 Spencer St.', 'Another house.'); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + $this->assertSame('117 Spencer St.', $model->address->lineOne); + $this->assertSame('110 Kingsbrook St.', $model->getOriginal()['address_line_one']); + + $model = new TestEloquentModelWithAttributeCast([ + 'address' => new AttributeCastAddress('110 Kingsbrook St.', 'My Childhood House'), + ]); + + $model->syncOriginal(); + + $model->address = null; + + $this->assertNull($model->address); + $this->assertInstanceOf(AttributeCastAddress::class, $model->getOriginal('address')); + $this->assertNull($model->address); + } + + public function testOneWayCasting() + { + $model = new TestEloquentModelWithAttributeCast; + + $model->password = 'secret'; + + $this->assertEquals(hash('sha256', 'secret'), $model->password); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret'), $model->password); + + $model->password = 'secret2'; + + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->getAttributes()['password']); + $this->assertEquals(hash('sha256', 'secret2'), $model->password); + } + + public function testSettingRawAttributesClearsTheCastCache() + { + $model = new TestEloquentModelWithAttributeCast; + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('110 Kingsbrook St.', $model->address->lineOne); + + $model->setRawAttributes([ + 'address_line_one' => '117 Spencer St.', + 'address_line_two' => 'My Childhood House', + ]); + + $this->assertSame('117 Spencer St.', $model->address->lineOne); + } +} + +class TestEloquentModelWithAttributeCast extends Model +{ + /** + * The attributes that aren't mass assignable. + * + * @var string[] + */ + protected $guarded = []; + + public function uppercase(): Attribute + { + return new Attribute( + function ($value) { + return strtoupper($value); + }, + function ($value) { + return strtoupper($value); + } + ); + } + + public function address(): Attribute + { + return new Attribute( + function ($value, $attributes) { + if (is_null($attributes['address_line_one'])) { + return; + } + + return new AttributeCastAddress($attributes['address_line_one'], $attributes['address_line_two']); + }, + function ($value) { + if (is_null($value)) { + return [ + 'address_line_one' => null, + 'address_line_two' => null, + ]; + } + + return ['address_line_one' => $value->lineOne, 'address_line_two' => $value->lineTwo]; + } + ); + } + + public function options(): Attribute + { + return new Attribute( + function ($value) { + return json_decode($value, true); + }, + function ($value) { + return json_encode($value); + } + ); + } + + public function birthdayAt(): Attribute + { + return new Attribute( + function ($value) { + return Carbon::parse($value); + }, + function ($value) { + return $value->format('Y-m-d'); + } + ); + } + + public function password(): Attribute + { + return new Attribute(null, function ($value) { + return hash('sha256', $value); + }); + } +} + +class AttributeCastAddress +{ + public $lineOne; + public $lineTwo; + + public function __construct($lineOne, $lineTwo) + { + $this->lineOne = $lineOne; + $this->lineTwo = $lineTwo; + } +}