From 7befbb6020d027d84f0bbd6b8b5bf39c71d5b70d Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sat, 4 Jan 2020 13:30:21 -0600 Subject: [PATCH 01/10] add custom cast support using classes - first pass --- .../Eloquent/Concerns/HasAttributes.php | 149 ++++++++++++++++-- src/Illuminate/Database/Eloquent/Model.php | 18 ++- ...DatabaseEloquentModelCustomCastingTest.php | 96 +++++++++++ 3 files changed, 250 insertions(+), 13 deletions(-) create mode 100644 tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 8563b4372dc6..e6cc279acd6e 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -44,6 +44,38 @@ trait HasAttributes */ protected $casts = []; + /** + * The attributes that have been cast using custom classes. + * + * @var array + */ + protected $classCastCache = []; + + /** + * The built-in, primitive cast types supported by Eloquent. + * + * @var array + */ + protected static $primitiveCastTypes = [ + 'array', + 'bool', + 'boolean', + 'collection', + 'custom_datetime', + 'date', + 'datetime', + 'decimal', + 'double', + 'float', + 'int', + 'integer', + 'json', + 'object', + 'real', + 'string', + 'timestamp', + ]; + /** * The attributes that should be mutated to dates. * @@ -173,7 +205,9 @@ protected function addMutatedAttributesToArray(array $attributes, array $mutated protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes) { foreach ($this->getCasts() as $key => $value) { - if (! array_key_exists($key, $attributes) || in_array($key, $mutatedAttributes)) { + if (! array_key_exists($key, $attributes) || + in_array($key, $mutatedAttributes) || + $this->isClassCastable($key)) { continue; } @@ -211,7 +245,7 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt */ protected function getArrayableAttributes() { - return $this->getArrayableItems($this->attributes); + return $this->getArrayableItems($this->getAttributes()); } /** @@ -318,8 +352,9 @@ public function getAttribute($key) // If the attribute exists in the attribute array or has a "get" mutator we will // get the attribute's value. Otherwise, we will proceed as if the developers // are asking for a relationship's value. This covers both types of values. - if (array_key_exists($key, $this->attributes) || - $this->hasGetMutator($key)) { + if (array_key_exists($key, $this->getAttributes()) || + $this->hasGetMutator($key) || + $this->isClassCastable($key)) { return $this->getAttributeValue($key); } @@ -352,7 +387,7 @@ public function getAttributeValue($key) */ protected function getAttributeFromArray($key) { - return $this->attributes[$key] ?? null; + return $this->getAttributes()[$key] ?? null; } /** @@ -439,7 +474,9 @@ protected function mutateAttribute($key, $value) */ protected function mutateAttributeForArray($key, $value) { - $value = $this->mutateAttribute($key, $value); + $value = $this->isClassCastable($key) + ? $this->castUsingClass($key) + : $this->mutateAttribute($key, $value); return $value instanceof Arrayable ? $value->toArray() : $value; } @@ -453,11 +490,13 @@ protected function mutateAttributeForArray($key, $value) */ protected function castAttribute($key, $value) { - if (is_null($value)) { + $castType = $this->getCastType($key); + + if (is_null($value) && in_array($castType, static::$primitiveCastTypes)) { return $value; } - switch ($this->getCastType($key)) { + switch ($castType) { case 'int': case 'integer': return (int) $value; @@ -486,11 +525,32 @@ protected function castAttribute($key, $value) return $this->asDateTime($value); case 'timestamp': return $this->asTimestamp($value); - default: - return $value; } + + if ($this->isClassCastable($key)) { + return $this->castUsingClass($key); + } + + return $value; } + /** + * Cast the given attribute using a custom cast class. + * + * @param string $key + * @return mixed + */ + protected function castUsingClass($key) + { + if (isset($this->classCastCache[$key])) { + return $this->classCastCache[$key]; + } else { + return $this->classCastCache[$key] = forward_static_call( + [$this->getCasts()[$key], 'fromModelAttributes'], $this, $key, $this->attributes + ); + } + } + /** * Get the type of cast for a model attribute. * @@ -556,6 +616,12 @@ public function setAttribute($key, $value) $value = $this->fromDateTime($value); } + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + + return $this; + } + if ($this->isJsonCastable($key) && ! is_null($value)) { $value = $this->castAttributeAsJson($key, $value); } @@ -625,6 +691,26 @@ public function fillJsonAttribute($key, $value) return $this; } + /** + * Set the value of a class castable attribute. + * + * @param string $key + * @param mixed $value + * @return void + */ + protected function setClassCastableAttribute($key, $value) + { + if (is_null($value)) { + $this->attributes = array_merge($this->attributes, array_map( + function () { return null; }, $this->castToClass($key)->toModelAttributes($this) + )); + + unset($this->classCastCache[$key]); + } else { + $this->classCastCache[$key] = $value; + } + } + /** * Get an array attribute with the given key and value set. * @@ -926,6 +1012,41 @@ protected function isJsonCastable($key) return $this->hasCast($key, ['array', 'json', 'object', 'collection']); } + /** + * Determine if the given key is cast using a custom class. + * + * @param string $key + * @return bool + */ + protected function isClassCastable($key) + { + if (! array_key_exists($key, $this->getCasts())) { + return false; + } + + $class = $this->getCasts()[$key]; + + return class_exists($class) && ! in_array($class, static::$primitiveCastTypes); + } + + /** + * Merge the cast class attributes back into the model. + * + * @return void + */ + protected function mergeAttributesFromClassCasts() + { + foreach ($this->classCastCache as $key => $value) { + $this->attributes = array_merge( + $this->attributes, + forward_static_call( + [$this->getCasts()[$key], 'toModelAttributes'], + $this, $key, $value, $this->attributes + ) + ); + } + } + /** * Get all of the current attributes on the model. * @@ -933,6 +1054,8 @@ protected function isJsonCastable($key) */ public function getAttributes() { + $this->mergeAttributesFromClassCasts(); + return $this->attributes; } @@ -998,7 +1121,7 @@ public function only($attributes) */ public function syncOriginal() { - $this->original = $this->attributes; + $this->original = $this->getAttributes(); return $this; } @@ -1024,8 +1147,10 @@ public function syncOriginalAttributes($attributes) { $attributes = is_array($attributes) ? $attributes : func_get_args(); + $modelAttributes = $this->getAttributes(); + foreach ($attributes as $attribute) { - $this->original[$attribute] = $this->attributes[$attribute]; + $this->original[$attribute] = $modelAttributes[$attribute]; } return $this; diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index eb8a4e692c8c..0743d650f7fe 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -665,6 +665,8 @@ public function push() */ public function save(array $options = []) { + $this->mergeAttributesFromClassCasts(); + $query = $this->newModelQuery(); // If the "saving" event returns false we'll bail out of the save and return @@ -905,6 +907,8 @@ public static function destroy($ids) */ public function delete() { + $this->mergeAttributesFromClassCasts(); + if (is_null($this->getKeyName())) { throw new Exception('No primary key defined on model.'); } @@ -1194,7 +1198,7 @@ public function replicate(array $except = null) ]; $attributes = Arr::except( - $this->attributes, $except ? array_unique(array_merge($except, $defaults)) : $defaults + $this->getAttributes(), $except ? array_unique(array_merge($except, $defaults)) : $defaults ); return tap(new static, function ($instance) use ($attributes) { @@ -1676,6 +1680,18 @@ public function __toString() return $this->toJson(); } + /** + * Prepare the object for serialization. + * + * @return array + */ + public function __sleep() + { + $this->mergeAttributesFromClassCasts(); + + return array_keys(get_object_vars($this)); + } + /** * When a model is being unserialized, check if it needs to be booted. * diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php new file mode 100644 index 000000000000..81c286a81296 --- /dev/null +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -0,0 +1,96 @@ +encrypted = 'taylor'; + + $this->assertEquals('taylor', $model->encrypted); + $this->assertEquals('rolyat', $model->getAttributes()['encrypted']); + $this->assertEquals('rolyat', $model->toArray()['encrypted']); + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My House', + ]); + + $this->assertEquals('110 Kingsbrook St.', $model->address->lineOne); + $this->assertEquals('My House', $model->address->lineTwo); + + $this->assertEquals('110 Kingsbrook St.', $model->toArray()['address_line_one']); + $this->assertEquals('My House', $model->toArray()['address_line_two']); + + $model->address->lineOne = '117 Spencer St.'; + + $this->assertFalse(isset($model->toArray()['address'])); + $this->assertEquals('117 Spencer St.', $model->toArray()['address_line_one']); + $this->assertEquals('My House', $model->toArray()['address_line_two']); + + $this->assertEquals('117 Spencer St.', json_decode($model->toJson(), true)['address_line_one']); + $this->assertEquals('My House', json_decode($model->toJson(), true)['address_line_two']); + } +} + +class TestEloquentModelWithCustomCast extends Model +{ + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'address' => AddressCaster::class, + 'encrypted' => EncryptCaster::class, + ]; +} + +class EncryptCaster +{ + public static function fromModelAttributes($model, $key, $attributes) + { + return strrev($attributes[$key]); + } + + public static function toModelAttributes($model, $key, $value, $attributes) + { + return [$key => strrev($value)]; + } +} + +class AddressCaster +{ + public static function fromModelAttributes($model, $key, $attributes) + { + return new Address($attributes['address_line_one'], $attributes['address_line_two']); + } + + public static function toModelAttributes($model, $key, $value, $attributes) + { + return ['address_line_one' => $value->lineOne, 'address_line_two' => $value->lineTwo]; + } +} + +class Address +{ + public $lineOne; + public $lineTwo; + + public function __construct($lineOne, $lineTwo) + { + $this->lineOne = $lineOne; + $this->lineTwo = $lineTwo; + } +} From 33d7f2fe80af84a9569d4fece0758a78fc3858ee Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sat, 4 Jan 2020 18:51:56 -0600 Subject: [PATCH 02/10] add to tests --- .../Database/Eloquent/Concerns/HasAttributes.php | 8 ++++++-- .../Database/DatabaseEloquentModelCustomCastingTest.php | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index e6cc279acd6e..9583e74843bd 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -546,7 +546,7 @@ protected function castUsingClass($key) return $this->classCastCache[$key]; } else { return $this->classCastCache[$key] = forward_static_call( - [$this->getCasts()[$key], 'fromModelAttributes'], $this, $key, $this->attributes + [$this->getCasts()[$key], 'fromModelAttributes'], $this, $key, $this->attributes[$key] ?? null, $this->attributes ); } } @@ -702,7 +702,11 @@ protected function setClassCastableAttribute($key, $value) { if (is_null($value)) { $this->attributes = array_merge($this->attributes, array_map( - function () { return null; }, $this->castToClass($key)->toModelAttributes($this) + function () { return null; }, + forward_static_call( + [$this->getCasts()[$key], 'toModelAttributes'], + $this, $key, $this->{$key}, $this->attributes + ) )); unset($this->classCastCache[$key]); diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php index 81c286a81296..e3e6a3a7f883 100644 --- a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -41,6 +41,11 @@ public function testBasicCustomCasting() $this->assertEquals('117 Spencer St.', json_decode($model->toJson(), true)['address_line_one']); $this->assertEquals('My 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']); } } @@ -72,7 +77,7 @@ public static function toModelAttributes($model, $key, $value, $attributes) class AddressCaster { - public static function fromModelAttributes($model, $key, $attributes) + public static function fromModelAttributes($model, $key, $value, $attributes) { return new Address($attributes['address_line_one'], $attributes['address_line_two']); } From b434944bd836131ac2c0db1246952faf74010099 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sat, 4 Jan 2020 20:11:34 -0600 Subject: [PATCH 03/10] add contract... add tests --- .../Database/Eloquent/CastsAttributes.php | 28 ++++++++ .../Eloquent/Concerns/HasAttributes.php | 62 ++++++++++------ ...DatabaseEloquentModelCustomCastingTest.php | 72 +++++++++++++++---- 3 files changed, 127 insertions(+), 35 deletions(-) create mode 100644 src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php diff --git a/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php b/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php new file mode 100644 index 000000000000..4a12ce413e71 --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php @@ -0,0 +1,28 @@ +isClassCastable($key) - ? $this->castUsingClass($key) + ? $this->getClassCastableAttribute($key) : $this->mutateAttribute($key, $value); return $value instanceof Arrayable ? $value->toArray() : $value; @@ -528,7 +528,7 @@ protected function castAttribute($key, $value) } if ($this->isClassCastable($key)) { - return $this->castUsingClass($key); + return $this->getClassCastableAttribute($key); } return $value; @@ -540,14 +540,14 @@ protected function castAttribute($key, $value) * @param string $key * @return mixed */ - protected function castUsingClass($key) + protected function getClassCastableAttribute($key) { if (isset($this->classCastCache[$key])) { return $this->classCastCache[$key]; } else { - return $this->classCastCache[$key] = forward_static_call( - [$this->getCasts()[$key], 'fromModelAttributes'], $this, $key, $this->attributes[$key] ?? null, $this->attributes - ); + return $this->classCastCache[$key] = $this->resolveCasterClass($key)->get( + $this, $key, $this->attributes[$key] ?? null, $this->attributes + ); } } @@ -700,19 +700,19 @@ public function fillJsonAttribute($key, $value) */ protected function setClassCastableAttribute($key, $value) { - if (is_null($value)) { - $this->attributes = array_merge($this->attributes, array_map( - function () { return null; }, - forward_static_call( - [$this->getCasts()[$key], 'toModelAttributes'], - $this, $key, $this->{$key}, $this->attributes - ) - )); - - unset($this->classCastCache[$key]); - } else { - $this->classCastCache[$key] = $value; - } + if (is_null($value)) { + $this->attributes = array_merge($this->attributes, array_map( + function () { return null; }, + $this->resolveCasterClass($key)->set($this, $key, $this->{$key}, $this->attributes) + )); + } else { + $this->attributes = array_merge( + $this->attributes, + $this->resolveCasterClass($key)->set($this, $key, $value, $this->attributes) + ); + } + + unset($this->classCastCache[$key]); } /** @@ -1033,6 +1033,25 @@ protected function isClassCastable($key) return class_exists($class) && ! in_array($class, static::$primitiveCastTypes); } + /** + * Resolve the custom caster class for a given key. + * + * @param string $key + * @return mixed + */ + protected function resolveCasterClass($key) + { + $castType = $this->getCasts()[$key]; + + if (strpos($castType, ':') === false) { + return new $castType; + } + + $segments = explode(':', $castType, 2); + + return new $segments[0](...explode(',', $segments[1])); + } + /** * Merge the cast class attributes back into the model. * @@ -1043,10 +1062,7 @@ protected function mergeAttributesFromClassCasts() foreach ($this->classCastCache as $key => $value) { $this->attributes = array_merge( $this->attributes, - forward_static_call( - [$this->getCasts()[$key], 'toModelAttributes'], - $this, $key, $value, $this->attributes - ) + $this->resolveCasterClass($key)->set($this, $key, $value, $this->attributes) ); } } diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php index e3e6a3a7f883..062c97dbe706 100644 --- a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -2,6 +2,7 @@ namespace Illuminate\Tests\Integration\Database; +use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Carbon; @@ -16,11 +17,11 @@ class DatabaseEloquentModelCustomCastingTest extends DatabaseTestCase public function testBasicCustomCasting() { $model = new TestEloquentModelWithCustomCast; - $model->encrypted = 'taylor'; + $model->reversed = 'taylor'; - $this->assertEquals('taylor', $model->encrypted); - $this->assertEquals('rolyat', $model->getAttributes()['encrypted']); - $this->assertEquals('rolyat', $model->toArray()['encrypted']); + $this->assertEquals('taylor', $model->reversed); + $this->assertEquals('rolyat', $model->getAttributes()['reversed']); + $this->assertEquals('rolyat', $model->toArray()['reversed']); $model->setRawAttributes([ 'address_line_one' => '110 Kingsbrook St.', @@ -46,6 +47,18 @@ public function testBasicCustomCasting() $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(json_encode(['foo' => 'bar']), $model->getAttributes()['options']); + } + + public function testOneWayCasting() + { + $model = new TestEloquentModelWithCustomCast; + + $model->password = 'secret'; + dd($model->password); } } @@ -58,36 +71,71 @@ class TestEloquentModelWithCustomCast extends Model */ protected $casts = [ 'address' => AddressCaster::class, - 'encrypted' => EncryptCaster::class, + 'password' => HashCaster::class, + 'other_password' => HashCaster::class.':md5', + 'reversed' => ReverseCaster::class, + 'options' => JsonCaster::class, ]; } -class EncryptCaster +class HashCaster implements CastsAttributes { - public static function fromModelAttributes($model, $key, $attributes) + public function __construct($algorithm = 'sha256') { - return strrev($attributes[$key]); + $this->algorithm = $algorithm; } - public static function toModelAttributes($model, $key, $value, $attributes) + public function get($model, $key, $value, $attributes) + { + return $value; + } + + public function set($model, $key, $value, $attributes) + { + dump('here'); + return [$key => hash($this->algorithm, $value)]; + } +} + +class ReverseCaster implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + return strrev($value); + } + + public function set($model, $key, $value, $attributes) { return [$key => strrev($value)]; } } -class AddressCaster +class AddressCaster implements CastsAttributes { - public static function fromModelAttributes($model, $key, $value, $attributes) + public function get($model, $key, $value, $attributes) { return new Address($attributes['address_line_one'], $attributes['address_line_two']); } - public static function toModelAttributes($model, $key, $value, $attributes) + public function set($model, $key, $value, $attributes) { return ['address_line_one' => $value->lineOne, 'address_line_two' => $value->lineTwo]; } } +class JsonCaster implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + return json_decode($value, true); + } + + public function set($model, $key, $value, $attributes) + { + return [$key => json_encode($value)]; + } +} + class Address { public $lineOne; From bce60ef56d03ce47bb1f3143e594081770024177 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sun, 5 Jan 2020 10:15:35 -0600 Subject: [PATCH 04/10] fix support for one way casting --- .../Eloquent/CastsInboundAttributes.php | 17 +++++++ .../Eloquent/Concerns/HasAttributes.php | 51 +++++++++++-------- ...DatabaseEloquentModelCustomCastingTest.php | 48 ++++++++++++++--- 3 files changed, 87 insertions(+), 29 deletions(-) create mode 100644 src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php diff --git a/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php b/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php new file mode 100644 index 000000000000..b9e3f922e689 --- /dev/null +++ b/src/Illuminate/Contracts/Database/Eloquent/CastsInboundAttributes.php @@ -0,0 +1,17 @@ +classCastCache[$key])) { - return $this->classCastCache[$key]; - } else { - return $this->classCastCache[$key] = $this->resolveCasterClass($key)->get( - $this, $key, $this->attributes[$key] ?? null, $this->attributes - ); - } - } + protected function getClassCastableAttribute($key) + { + if (isset($this->classCastCache[$key])) { + return $this->classCastCache[$key]; + } else { + $caster = $this->resolveCasterClass($key); + + return $this->classCastCache[$key] = $caster instanceof CastsInboundAttributes + ? $this->attributes[$key] + : $caster->get($this, $key, $this->attributes[$key] ?? null, $this->attributes); + } + } /** * Get the type of cast for a model attribute. @@ -692,14 +695,14 @@ public function fillJsonAttribute($key, $value) } /** - * Set the value of a class castable attribute. - * - * @param string $key - * @param mixed $value - * @return void - */ - protected function setClassCastableAttribute($key, $value) - { + * Set the value of a class castable attribute. + * + * @param string $key + * @param mixed $value + * @return void + */ + protected function setClassCastableAttribute($key, $value) + { if (is_null($value)) { $this->attributes = array_merge($this->attributes, array_map( function () { return null; }, @@ -712,8 +715,8 @@ function () { return null; }, ); } - unset($this->classCastCache[$key]); - } + unset($this->classCastCache[$key]); + } /** * Get an array attribute with the given key and value set. @@ -1060,9 +1063,13 @@ protected function resolveCasterClass($key) protected function mergeAttributesFromClassCasts() { foreach ($this->classCastCache as $key => $value) { + $caster = $this->resolveCasterClass($key); + $this->attributes = array_merge( $this->attributes, - $this->resolveCasterClass($key)->set($this, $key, $value, $this->attributes) + $caster instanceof CastsInboundAttributes + ? [$key => $value] + : $caster->set($this, $key, $value, $this->attributes) ); } } @@ -1094,6 +1101,8 @@ public function setRawAttributes(array $attributes, $sync = false) $this->syncOriginal(); } + $this->classCastCache = []; + return $this; } diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php index 062c97dbe706..8f08784c8069 100644 --- a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -3,6 +3,7 @@ namespace Illuminate\Tests\Integration\Database; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Carbon; @@ -50,15 +51,52 @@ public function testBasicCustomCasting() $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->assertEquals(json_encode(['foo' => 'bar']), $model->getAttributes()['options']); } public function testOneWayCasting() { + // CastsInboundAttributes is used for casting that is unidirectional... only use case I can think of is one-way hashing... $model = new TestEloquentModelWithCustomCast; $model->password = 'secret'; - dd($model->password); + + $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 TestEloquentModelWithCustomCast; + + $model->setRawAttributes([ + 'address_line_one' => '110 Kingsbrook St.', + 'address_line_two' => 'My House', + ]); + + $this->assertEquals('110 Kingsbrook St.', $model->address->lineOne); + + $model->setRawAttributes([ + 'address_line_one' => '117 Spencer St.', + 'address_line_two' => 'My House', + ]); + + $this->assertEquals('117 Spencer St.', $model->address->lineOne); } } @@ -78,21 +116,15 @@ class TestEloquentModelWithCustomCast extends Model ]; } -class HashCaster implements CastsAttributes +class HashCaster implements CastsInboundAttributes { public function __construct($algorithm = 'sha256') { $this->algorithm = $algorithm; } - public function get($model, $key, $value, $attributes) - { - return $value; - } - public function set($model, $key, $value, $attributes) { - dump('here'); return [$key => hash($this->algorithm, $value)]; } } From 6f2203c0c7816f6f71b150c92c298b6a635923c4 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sun, 5 Jan 2020 11:06:15 -0600 Subject: [PATCH 05/10] add test --- src/Illuminate/Database/Eloquent/Model.php | 2 ++ .../Database/DatabaseEloquentModelCustomCastingTest.php | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 0743d650f7fe..2fc9eb0bd342 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -1689,6 +1689,8 @@ public function __sleep() { $this->mergeAttributesFromClassCasts(); + $this->classCastCache = []; + return array_keys(get_object_vars($this)); } diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php index 8f08784c8069..0f82d6ba31f9 100644 --- a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -24,6 +24,12 @@ public function testBasicCustomCasting() $this->assertEquals('rolyat', $model->getAttributes()['reversed']); $this->assertEquals('rolyat', $model->toArray()['reversed']); + $unserializedModel = unserialize(serialize($model)); + + $this->assertEquals('taylor', $unserializedModel->reversed); + $this->assertEquals('rolyat', $unserializedModel->getAttributes()['reversed']); + $this->assertEquals('rolyat', $unserializedModel->toArray()['reversed']); + $model->setRawAttributes([ 'address_line_one' => '110 Kingsbrook St.', 'address_line_two' => 'My House', From 08360642a3895980618de4db02aafcb20ea02009 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sun, 5 Jan 2020 14:10:50 -0600 Subject: [PATCH 06/10] shorten method --- .../Database/Eloquent/Concerns/HasAttributes.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index f7ca82dee5e0..124670116562 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -1027,13 +1027,9 @@ protected function isJsonCastable($key) */ protected function isClassCastable($key) { - if (! array_key_exists($key, $this->getCasts())) { - return false; - } - - $class = $this->getCasts()[$key]; - - return class_exists($class) && ! in_array($class, static::$primitiveCastTypes); + return array_key_exists($key, $this->getCasts()) && + class_exists($class = $this->getCasts()[$key]) && + ! in_array($class, static::$primitiveCastTypes); } /** From 953c664111469ecb629c44118ea541f13044752d Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sun, 5 Jan 2020 14:11:25 -0600 Subject: [PATCH 07/10] shorten method --- src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 124670116562..d64bd50c8eae 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -1040,9 +1040,7 @@ class_exists($class = $this->getCasts()[$key]) && */ protected function resolveCasterClass($key) { - $castType = $this->getCasts()[$key]; - - if (strpos($castType, ':') === false) { + if (strpos($castType = $this->getCasts()[$key], ':') === false) { return new $castType; } From 6026ef2a99b72e822194f2935cb93da8b1cee9a6 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sun, 5 Jan 2020 14:19:10 -0600 Subject: [PATCH 08/10] allow single value response from custom caster --- .../Eloquent/Concerns/HasAttributes.php | 46 +++++++++++++------ ...DatabaseEloquentModelCustomCastingTest.php | 2 +- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index d64bd50c8eae..5102ccf04fbd 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -706,12 +706,16 @@ protected function setClassCastableAttribute($key, $value) if (is_null($value)) { $this->attributes = array_merge($this->attributes, array_map( function () { return null; }, - $this->resolveCasterClass($key)->set($this, $key, $this->{$key}, $this->attributes) + $this->normalizeCastClassResponse($key, $this->resolveCasterClass($key)->set( + $this, $key, $this->{$key}, $this->attributes + )) )); } else { $this->attributes = array_merge( $this->attributes, - $this->resolveCasterClass($key)->set($this, $key, $value, $this->attributes) + $this->normalizeCastClassResponse($key, $this->resolveCasterClass($key)->set( + $this, $key, $value, $this->attributes + )) ); } @@ -1050,23 +1054,35 @@ protected function resolveCasterClass($key) } /** - * Merge the cast class attributes back into the model. - * - * @return void - */ - protected function mergeAttributesFromClassCasts() - { - foreach ($this->classCastCache as $key => $value) { + * Merge the cast class attributes back into the model. + * + * @return void + */ + protected function mergeAttributesFromClassCasts() + { + foreach ($this->classCastCache as $key => $value) { $caster = $this->resolveCasterClass($key); - $this->attributes = array_merge( + $this->attributes = array_merge( $this->attributes, $caster instanceof CastsInboundAttributes - ? [$key => $value] - : $caster->set($this, $key, $value, $this->attributes) - ); - } - } + ? [$key => $value] + : $this->normalizeCastClassResponse($key, $caster->set($this, $key, $value, $this->attributes)) + ); + } + } + + /** + * Normalize the response from a custom class caster. + * + * @param string $key + * @param mixed $value + * @return array + */ + protected function normalizeCastClassResponse($key, $value) + { + return is_array($value) ? $value : [$key => $value]; + } /** * Get all of the current attributes on the model. diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php index 0f82d6ba31f9..efc0da7cedae 100644 --- a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -170,7 +170,7 @@ public function get($model, $key, $value, $attributes) public function set($model, $key, $value, $attributes) { - return [$key => json_encode($value)]; + return json_encode($value); } } From ae08973d29f8ba8a49d8d106e467e11817c79731 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sun, 5 Jan 2020 14:21:12 -0600 Subject: [PATCH 09/10] Apply fixes from StyleCI (#31036) --- .../Eloquent/Concerns/HasAttributes.php | 17 ++++++------- src/Illuminate/Database/Eloquent/Model.php | 24 +++++++++---------- ...DatabaseEloquentModelCustomCastingTest.php | 4 ---- 3 files changed, 21 insertions(+), 24 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 5102ccf04fbd..a6c87ffb943d 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -536,11 +536,11 @@ protected function castAttribute($key, $value) } /** - * Cast the given attribute using a custom cast class. - * - * @param string $key - * @return mixed - */ + * Cast the given attribute using a custom cast class. + * + * @param string $key + * @return mixed + */ protected function getClassCastableAttribute($key) { if (isset($this->classCastCache[$key])) { @@ -705,7 +705,8 @@ protected function setClassCastableAttribute($key, $value) { if (is_null($value)) { $this->attributes = array_merge($this->attributes, array_map( - function () { return null; }, + function () { + }, $this->normalizeCastClassResponse($key, $this->resolveCasterClass($key)->set( $this, $key, $this->{$key}, $this->attributes )) @@ -719,7 +720,7 @@ function () { return null; }, ); } - unset($this->classCastCache[$key]); + unset($this->classCastCache[$key]); } /** @@ -1031,7 +1032,7 @@ protected function isJsonCastable($key) */ protected function isClassCastable($key) { - return array_key_exists($key, $this->getCasts()) && + return array_key_exists($key, $this->getCasts()) && class_exists($class = $this->getCasts()[$key]) && ! in_array($class, static::$primitiveCastTypes); } diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 2fc9eb0bd342..0e1f50103265 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -1681,18 +1681,18 @@ public function __toString() } /** - * Prepare the object for serialization. - * - * @return array - */ - public function __sleep() - { - $this->mergeAttributesFromClassCasts(); - - $this->classCastCache = []; - - return array_keys(get_object_vars($this)); - } + * Prepare the object for serialization. + * + * @return array + */ + public function __sleep() + { + $this->mergeAttributesFromClassCasts(); + + $this->classCastCache = []; + + return array_keys(get_object_vars($this)); + } /** * When a model is being unserialized, check if it needs to be booted. diff --git a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php index efc0da7cedae..f5594ee62bcd 100644 --- a/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -5,10 +5,6 @@ use Illuminate\Contracts\Database\Eloquent\CastsAttributes; use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Carbon; -use Illuminate\Support\Facades\Schema; -use Illuminate\Support\Str; /** * @group integration From 55dbaab56b2e75147329470975c8ce2c7f4135d3 Mon Sep 17 00:00:00 2001 From: Taylor Otwell Date: Sun, 5 Jan 2020 15:57:29 -0600 Subject: [PATCH 10/10] fix method name --- src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 5102ccf04fbd..03195e35fae5 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -476,7 +476,7 @@ protected function mutateAttribute($key, $value) protected function mutateAttributeForArray($key, $value) { $value = $this->isClassCastable($key) - ? $this->getClassCastableAttribute($key) + ? $this->getClassCastableAttributeValue($key) : $this->mutateAttribute($key, $value); return $value instanceof Arrayable ? $value->toArray() : $value; @@ -529,7 +529,7 @@ protected function castAttribute($key, $value) } if ($this->isClassCastable($key)) { - return $this->getClassCastableAttribute($key); + return $this->getClassCastableAttributeValue($key); } return $value; @@ -541,7 +541,7 @@ protected function castAttribute($key, $value) * @param string $key * @return mixed */ - protected function getClassCastableAttribute($key) + protected function getClassCastableAttributeValue($key) { if (isset($this->classCastCache[$key])) { return $this->classCastCache[$key];