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 @@ +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 +246,7 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt */ protected function getArrayableAttributes() { - return $this->getArrayableItems($this->attributes); + return $this->getArrayableItems($this->getAttributes()); } /** @@ -318,8 +353,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 +388,7 @@ public function getAttributeValue($key) */ protected function getAttributeFromArray($key) { - return $this->attributes[$key] ?? null; + return $this->getAttributes()[$key] ?? null; } /** @@ -439,7 +475,9 @@ protected function mutateAttribute($key, $value) */ protected function mutateAttributeForArray($key, $value) { - $value = $this->mutateAttribute($key, $value); + $value = $this->isClassCastable($key) + ? $this->getClassCastableAttributeValue($key) + : $this->mutateAttribute($key, $value); return $value instanceof Arrayable ? $value->toArray() : $value; } @@ -453,11 +491,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,8 +526,31 @@ protected function castAttribute($key, $value) return $this->asDateTime($value); case 'timestamp': return $this->asTimestamp($value); - default: - return $value; + } + + if ($this->isClassCastable($key)) { + return $this->getClassCastableAttributeValue($key); + } + + return $value; + } + + /** + * Cast the given attribute using a custom cast class. + * + * @param string $key + * @return mixed + */ + protected function getClassCastableAttributeValue($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); } } @@ -556,6 +619,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 +694,35 @@ 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 () { + }, + $this->normalizeCastClassResponse($key, $this->resolveCasterClass($key)->set( + $this, $key, $this->{$key}, $this->attributes + )) + )); + } else { + $this->attributes = array_merge( + $this->attributes, + $this->normalizeCastClassResponse($key, $this->resolveCasterClass($key)->set( + $this, $key, $value, $this->attributes + )) + ); + } + + unset($this->classCastCache[$key]); + } + /** * Get an array attribute with the given key and value set. * @@ -926,6 +1024,67 @@ 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) + { + return array_key_exists($key, $this->getCasts()) && + class_exists($class = $this->getCasts()[$key]) && + ! in_array($class, static::$primitiveCastTypes); + } + + /** + * Resolve the custom caster class for a given key. + * + * @param string $key + * @return mixed + */ + protected function resolveCasterClass($key) + { + if (strpos($castType = $this->getCasts()[$key], ':') === false) { + return new $castType; + } + + $segments = explode(':', $castType, 2); + + return new $segments[0](...explode(',', $segments[1])); + } + + /** + * 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, + $caster instanceof CastsInboundAttributes + ? [$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. * @@ -933,6 +1092,8 @@ protected function isJsonCastable($key) */ public function getAttributes() { + $this->mergeAttributesFromClassCasts(); + return $this->attributes; } @@ -951,6 +1112,8 @@ public function setRawAttributes(array $attributes, $sync = false) $this->syncOriginal(); } + $this->classCastCache = []; + return $this; } @@ -998,7 +1161,7 @@ public function only($attributes) */ public function syncOriginal() { - $this->original = $this->attributes; + $this->original = $this->getAttributes(); return $this; } @@ -1024,8 +1187,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..0e1f50103265 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,20 @@ public function __toString() return $this->toJson(); } + /** + * 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 new file mode 100644 index 000000000000..f5594ee62bcd --- /dev/null +++ b/tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php @@ -0,0 +1,183 @@ +reversed = 'taylor'; + + $this->assertEquals('taylor', $model->reversed); + $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', + ]); + + $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']); + + $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->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'; + + $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); + } +} + +class TestEloquentModelWithCustomCast extends Model +{ + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'address' => AddressCaster::class, + 'password' => HashCaster::class, + 'other_password' => HashCaster::class.':md5', + 'reversed' => ReverseCaster::class, + 'options' => JsonCaster::class, + ]; +} + +class HashCaster implements CastsInboundAttributes +{ + public function __construct($algorithm = 'sha256') + { + $this->algorithm = $algorithm; + } + + public function set($model, $key, $value, $attributes) + { + 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 implements CastsAttributes +{ + public function get($model, $key, $value, $attributes) + { + return new Address($attributes['address_line_one'], $attributes['address_line_two']); + } + + 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 json_encode($value); + } +} + +class Address +{ + public $lineOne; + public $lineTwo; + + public function __construct($lineOne, $lineTwo) + { + $this->lineOne = $lineOne; + $this->lineTwo = $lineTwo; + } +}