diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index e64bf3601c93..ff0f6c7036e7 100755 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -154,6 +154,13 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab */ protected $casts = []; + /** + * The attributes that have been cast to classes. + * + * @var array + */ + protected $classCastCache = []; + /** * The relationships that should be touched on save. * @@ -252,6 +259,16 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab */ public static $manyMethods = ['belongsToMany', 'morphToMany', 'morphedByMany']; + /** + * All of the valid primitive cast types. + * + * @var array + */ + protected static $primitiveCastTypes = [ + 'int', 'integer', 'real', 'float', 'double', 'string', 'bool', 'boolean', + 'object', 'array', 'json', 'collection', 'date', 'datetime', 'timestamp', + ]; + /** * The name of the "created at" column. * @@ -1097,6 +1114,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.'); } @@ -1448,6 +1467,8 @@ public function push() */ public function save(array $options = []) { + $this->mergeAttributesFromClassCasts(); + $query = $this->newQueryWithoutScopes(); // If the "saving" event returns false we'll bail out of the save and return @@ -2461,7 +2482,8 @@ public function attributesToArray() // will not perform the cast on those attributes to avoid any confusion. foreach ($this->getCasts() as $key => $value) { if (! array_key_exists($key, $attributes) || - in_array($key, $mutatedAttributes)) { + in_array($key, $mutatedAttributes) || + $this->isClassCastable($key)) { continue; } @@ -2491,7 +2513,7 @@ public function attributesToArray() */ protected function getArrayableAttributes() { - return $this->getArrayableItems($this->attributes); + return $this->getArrayableItems($this->getAttributes()); } /** @@ -2587,7 +2609,7 @@ protected function getArrayableItems(array $values) */ public function getAttribute($key) { - if (array_key_exists($key, $this->attributes) || $this->hasGetMutator($key)) { + if (array_key_exists($key, $this->attributes) || $this->hasGetMutator($key) || $this->isClassCastable($key)) { return $this->getAttributeValue($key); } @@ -2659,8 +2681,8 @@ public function getRelationValue($key) */ protected function getAttributeFromArray($key) { - if (array_key_exists($key, $this->attributes)) { - return $this->attributes[$key]; + if (array_key_exists($key, $attributes = $this->getAttributes())) { + return $attributes[$key]; } } @@ -2718,7 +2740,11 @@ protected function mutateAttribute($key, $value) */ protected function mutateAttributeForArray($key, $value) { - $value = $this->mutateAttribute($key, $value); + if ($this->isClassCastable($key)) { + $value = $this->castToClass($key); + } else { + $value = $this->mutateAttribute($key, $value); + } return $value instanceof Arrayable ? $value->toArray() : $value; } @@ -2777,6 +2803,37 @@ protected function isJsonCastable($key) return $this->hasCast($key, ['array', 'json', 'object', 'collection']); } + /** + * Determine whether a value is JSON castable for inbound manipulation. + * + * @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, $value->toModelAttributes($this, $this->attributes) + ); + } + } + /** * Get the type of cast for a model attribute. * @@ -2797,11 +2854,13 @@ protected function getCastType($key) */ 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; @@ -2826,8 +2885,29 @@ protected function castAttribute($key, $value) return $this->asDateTime($value); case 'timestamp': return $this->asTimeStamp($value); - default: - return $value; + } + + if ($this->isClassCastable($key)) { + return $this->castToClass($key); + } + + return $value; + } + + /** + * Cast the given attribute to a class. + * + * @param string $key + * @return mixed + */ + protected function castToClass($key) + { + if (isset($this->classCastCache[$key])) { + return $this->classCastCache[$key]; + } else { + return $this->classCastCache[$key] = forward_static_call( + [$this->getCasts()[$key], 'fromModelAttributes'], $this, $this->attributes + ); } } @@ -2856,15 +2936,37 @@ public function setAttribute($key, $value) $value = $this->fromDateTime($value); } - if ($this->isJsonCastable($key) && ! is_null($value)) { - $value = $this->asJson($value); + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + } elseif ($this->isJsonCastable($key) && ! is_null($value)) { + $this->attributes[$key] = $this->asJson($value); + } else { + $this->attributes[$key] = $value; } - $this->attributes[$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; + } + } + /** * Determine if a set mutator exists for an attribute. * @@ -3023,12 +3125,14 @@ public function fromJson($value, $asObject = false) */ public function replicate(array $except = null) { - $except = $except ?: [ + $defaults = [ $this->getKeyName(), $this->getCreatedAtColumn(), $this->getUpdatedAtColumn(), ]; + $except = $except ? array_unique(array_merge($except, $defaults)) : $defaults; + $attributes = Arr::except($this->attributes, $except); $instance = new static; @@ -3045,6 +3149,8 @@ public function replicate(array $except = null) */ public function getAttributes() { + $this->mergeAttributesFromClassCasts(); + return $this->attributes; } @@ -3513,6 +3619,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/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index b50635369357..9165ec599600 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -818,6 +818,39 @@ public function testForPageAfterIdCorrectlyPaginates() $this->assertEquals(1, count($results)); } + public function testAttributesMayBeCastToValueObjects() + { + $model = new EloquentTestValueObjectCast; + $model->line_one = 'Address Line 1'; + $model->line_two = 'Address Line 2'; + + $this->assertInstanceOf('EloquentTestAddressValueObject', $model->address); + $this->assertEquals('Address Line 1', $model->address->lineOne); + $this->assertEquals('Address Line 2', $model->address->lineTwo); + + $model->address->lineOne = 'Modified Line 1'; + $this->assertEquals('Modified Line 1', $model->line_one); + $this->assertEquals('Modified Line 1', $model->toArray()['line_one']); + + $model->address = new EloquentTestAddressValueObject('Fresh Line 1', 'Fresh Line 2'); + $this->assertEquals('Fresh Line 1', $model->line_one); + $this->assertEquals('Fresh Line 2', $model->line_two); + + $model->address = null; + $this->assertNull($model->line_one); + $this->assertNull($model->line_two); + + $model->address = new EloquentTestAddressValueObject('Fresh Line 1', 'Fresh Line 2'); + $model->forceFill(['address' => null]); + $this->assertNull($model->line_one); + $this->assertNull($model->line_two); + + $model->address = new EloquentTestAddressValueObject('Fresh Line 1', 'Fresh Line 2'); + $model->address->lineOne = 'Mutated Line 1'; + $model = unserialize(serialize($model)); + $this->assertEquals('Mutated Line 1', $model->line_one); + } + /** * Helpers... */ @@ -937,3 +970,33 @@ class EloquentTestUserWithStringCastId extends EloquentTestUser 'id' => 'string', ]; } + +class EloquentTestAddressValueObject implements Illuminate\Contracts\Support\Arrayable { + public $lineOne, $lineTwo; + public function __construct($lineOne, $lineTwo) + { + $this->lineOne = $lineOne; + $this->lineTwo = $lineTwo; + } + public static function fromModelAttributes($model, $attributes) + { + return new static($attributes['line_one'], $attributes['line_two']); + } + public function toModelAttributes() + { + return [ + 'line_one' => $this->lineOne, + 'line_two' => $this->lineTwo, + ]; + } + public function toArray() + { + return $this->toModelAttributes(); + } +} + +class EloquentTestValueObjectCast extends Eloquent { + protected $casts = [ + 'address' => 'EloquentTestAddressValueObject', + ]; +}