From 1edc6e76c3773b76e093825d6fa5745c85022a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till=20Kru=CC=88ss?= Date: Fri, 24 Feb 2017 10:19:09 -0800 Subject: [PATCH 1/2] Port object casting (#13706) to master --- .../Eloquent/Concerns/HasAttributes.php | 131 ++++++++++++++++-- src/Illuminate/Database/Eloquent/Model.php | 16 +++ .../DatabaseEloquentIntegrationTest.php | 71 ++++++++++ 3 files changed, 207 insertions(+), 11 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 1654e39b12b7..8ee1eb068de4 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -35,6 +35,13 @@ trait HasAttributes */ protected $casts = []; + /** + * The attributes that have been cast to classes. + * + * @var array + */ + protected $classCastCache = []; + /** * The attributes that should be mutated to dates. * @@ -70,6 +77,16 @@ trait HasAttributes */ protected static $mutatorCache = []; + /** + * 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', + ]; + /** * Convert the model's attributes to an array. * @@ -168,6 +185,10 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt continue; } + if ($this->isClassCastable($key)) { + continue; + } + // Here we will cast the attribute. Then, if the cast is a date or datetime cast // then we will serialize the date for the array. This will convert the dates // to strings based on the date format specified for these Eloquent models. @@ -194,7 +215,7 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt */ protected function getArrayableAttributes() { - return $this->getArrayableItems($this->attributes); + return $this->getArrayableItems($this->getAttributes()); } /** @@ -301,8 +322,7 @@ 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->attributes) || $this->hasGetMutator($key) || $this->isClassCastable($key)) { return $this->getAttributeValue($key); } @@ -359,8 +379,10 @@ public function getAttributeValue($key) */ protected function getAttributeFromArray($key) { - if (isset($this->attributes[$key])) { - return $this->attributes[$key]; + $attributes = $this->getAttributes(); + + if (isset($attributes[$key])) { + return $attributes[$key]; } } @@ -441,7 +463,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; } @@ -455,11 +481,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; @@ -485,8 +513,60 @@ 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 + ); + } + } + + /** + * 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) + ); } } @@ -537,11 +617,38 @@ public function setAttribute($key, $value) return $this->fillJsonAttribute($key, $value); } - $this->attributes[$key] = $value; + if ($this->isClassCastable($key)) { + $this->setClassCastableAttribute($key, $value); + } else { + $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. * @@ -850,6 +957,8 @@ protected function isJsonCastable($key) */ public function getAttributes() { + $this->mergeAttributesFromClassCasts(); + return $this->attributes; } diff --git a/src/Illuminate/Database/Eloquent/Model.php b/src/Illuminate/Database/Eloquent/Model.php index 32104e34a88f..a6e26933dc45 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -484,6 +484,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 @@ -714,6 +716,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.'); } @@ -1338,6 +1342,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 33ca8553fe7f..8397fe44c0a4 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -4,6 +4,7 @@ use Exception; use PHPUnit\Framework\TestCase; +use Illuminate\Contracts\Support\Arrayable; use Illuminate\Database\Capsule\Manager as DB; use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\Model as Eloquent; @@ -1061,6 +1062,39 @@ public function testBelongsToManyCustomPivot() $this->assertEquals('Jule Doe', $johnWithFriends->friends->find(4)->pivot->friend->name); } + public function testAttributesMayBeCastToValueObjects() + { + $model = new EloquentTestValueObjectCast; + $model->line_one = 'Address Line 1'; + $model->line_two = 'Address Line 2'; + + $this->assertInstanceOf(EloquentTestAddressValueObject::class, $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... */ @@ -1273,3 +1307,40 @@ public function level() return $this->belongsTo(EloquentTestFriendLevel::class, 'friend_level_id'); } } + +class EloquentTestAddressValueObject implements Arrayable +{ + public $lineOne; + public $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::class, + ]; +} From 3dce9b962be1ed65d72b9255fc26ab1322853cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Till=20Kru=CC=88ss?= Date: Fri, 24 Feb 2017 13:10:43 -0800 Subject: [PATCH 2/2] Pass attribute key to object for reusability --- .../Database/Eloquent/Concerns/HasAttributes.php | 7 ++++--- tests/Database/DatabaseEloquentIntegrationTest.php | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index 8ee1eb068de4..a225f1216492 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -534,7 +534,7 @@ protected function castToClass($key) return $this->classCastCache[$key]; } else { return $this->classCastCache[$key] = forward_static_call( - [$this->getCasts()[$key], 'fromModelAttributes'], $this, $this->attributes + [$this->getCasts()[$key], 'fromModelAttributes'], $this->attributes, $key, $this ); } } @@ -565,7 +565,8 @@ protected function mergeAttributesFromClassCasts() { foreach ($this->classCastCache as $key => $value) { $this->attributes = array_merge( - $this->attributes, $value->toModelAttributes($this, $this->attributes) + $this->attributes, + $value->toModelAttributes($this->attributes, $key, $this) ); } } @@ -640,7 +641,7 @@ protected function setClassCastableAttribute($key, $value) function () { return null; }, - $this->castToClass($key)->toModelAttributes($this) + $this->castToClass($key)->toModelAttributes($this->attributes, $key, $this) )); unset($this->classCastCache[$key]); diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index 8397fe44c0a4..7d1340e0e0f1 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -1319,7 +1319,7 @@ public function __construct($lineOne, $lineTwo) $this->lineTwo = $lineTwo; } - public static function fromModelAttributes($model, $attributes) + public static function fromModelAttributes($attributes) { return new static($attributes['line_one'], $attributes['line_two']); }