diff --git a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php index ba436760fc22..ec0c834cf9d4 100644 --- a/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php +++ b/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php @@ -42,6 +42,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. * @@ -175,6 +182,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. @@ -201,7 +212,7 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt */ protected function getArrayableAttributes() { - return $this->getArrayableItems($this->attributes); + return $this->getArrayableItems($this->getAttributes()); } /** @@ -308,8 +319,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); } @@ -366,8 +376,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]; } } @@ -447,7 +459,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; } @@ -465,6 +481,10 @@ protected function castAttribute($key, $value) return $value; } + if ($this->isClassCastable($key)) { + return $this->castToClass($key); + } + switch ($this->getCastType($key)) { case 'int': case 'integer': @@ -491,8 +511,81 @@ protected function castAttribute($key, $value) return $this->asDateTime($value); case 'timestamp': return $this->asTimestamp($value); - default: - return $value; + } + + 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]; + } + + list($type, $attributes) = $this->getClassCast($key); + + if ($attributes) { + $parameters = array_map(function ($key) { + return $this->attributes[$key] ?? null; + }, $attributes); + } else { + $parameters = [$this->attributes[$key] ?? null]; + } + + return $this->classCastCache[$key] = $this->{'castTo'.Str::studly($type)}(...$parameters); + } + + /** + * Determine whether a value is class castable. + * + * @param string $key + * @return bool + */ + protected function isClassCastable($key) + { + $casts = array_map(function ($cast) { + return strpos($cast, ':') === false ? $cast : strstr($cast, ':', true); + }, $this->getCasts()); + + if (! array_key_exists($key, $casts)) { + return false; + } + + return method_exists($this, 'castTo'.Str::studly($casts[$key])); + } + + /** + * Merge the cast class attributes back into the model. + * + * @return void + */ + protected function mergeAttributesFromClassCasts() + { + foreach ($this->getCasts() as $attribute => $cast) { + if ($this->isClassCastable($attribute)) { + list($type, $attributes) = $this->getClassCast($attribute); + + $attributeCount = count($attributes); + $object = $this->castToClass($attribute); + + $castedAttributes = method_exists($this, 'castFrom'.Str::studly($type)) + ? $this->{'castFrom'.Str::studly($type)}($object) + : $object->__toString(); + + if ($attributeCount !== count($castedAttributes)) { + throw new LogicException("Class cast {$attribute} must return {$attributeCount} attribute(s)"); + } + + $this->attributes = array_merge( + $this->attributes, array_combine($attributes, (array) $castedAttributes) + ); + } } } @@ -543,11 +636,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)) { + list($cast, $attributes) = $this->getClassCast($key); + + $this->attributes = array_merge( + $this->attributes, + array_fill_keys((array) $attributes, null) + ); + + unset($this->classCastCache[$key]); + } else { + $this->classCastCache[$key] = $value; + } + } + /** * Determine if a set mutator exists for an attribute. * @@ -807,7 +927,10 @@ public function setDateFormat($format) public function hasCast($key, $types = null) { if (array_key_exists($key, $this->getCasts())) { - return $types ? in_array($this->getCastType($key), (array) $types, true) : true; + $cast = $this->getCastType($key); + $cast = strstr($cast, ':', true) ?: $cast; + + return $types ? in_array($cast, (array) $types, true) : true; } return false; @@ -827,6 +950,26 @@ public function getCasts() return $this->casts; } + /** + * Get the casted class and attributes for given attribute. + * + * @param string $attribute + * @return array + */ + protected function getClassCast($attribute) + { + $cast = $this->getCasts()[$attribute]; + + if (strpos($cast, ':') === false) { + return [$cast, [$attribute]]; + } + + return [ + strstr($cast, ':', true), + explode(',', ltrim(strstr($cast, ':'), ':')), + ]; + } + /** * Determine whether a value is Date / DateTime castable for inbound manipulation. * @@ -856,6 +999,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 e4097287722c..15cf87614e27 100644 --- a/src/Illuminate/Database/Eloquent/Model.php +++ b/src/Illuminate/Database/Eloquent/Model.php @@ -497,6 +497,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 @@ -728,6 +730,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.'); } @@ -1394,6 +1398,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 cecf0e14943e..f873fe16d0e6 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -1135,6 +1135,49 @@ public function testFreshMethodOnCollection() $this->assertEquals($users->map->fresh(), $users->fresh()); } + public function testAttributesMayBeCastToObjects() + { + $model = new EloquentTestObjectCast(); + + $model->token = new EloquentTestBase64Object('Taylor'); + $this->assertInstanceOf(EloquentTestBase64Object::class, $model->token); + $this->assertEquals('Taylor', $model->token->secret); + $this->assertEquals('VGF5bG9y', $model->toArray()['token']); + $this->assertTrue($model->hasCast('token', 'base64')); + + $model->price = (new EloquentTestMoneyObject(10))->setCurrency('BTC'); + $this->assertInstanceOf(EloquentTestMoneyObject::class, $model->price); + $this->assertEquals(10, $model->toArray()['price']); + $this->assertEquals('BTC', $model->toArray()['currency']); + $this->assertTrue($model->hasCast('price', 'money')); + + $model->price->amount = 20; + $this->assertEquals(20, $model->toArray()['price']); + + $model->price = (new EloquentTestMoneyObject(30)); + $this->assertEquals(30, $model->toArray()['price']); + $this->assertNull($model->toArray()['currency']); + + $model->price = null; + $this->assertNull($model->price); + $this->assertNull($model->toArray()['price']); + $this->assertNull($model->toArray()['currency']); + + $model->price = (new EloquentTestMoneyObject(10))->setCurrency('BTC'); + $model->forceFill(['price' => null]); + $this->assertNull($model->price); + $this->assertNull($model->toArray()['price']); + $this->assertNull($model->toArray()['currency']); + + $model->price = (new EloquentTestMoneyObject(10))->setCurrency('BTC'); + $model->price->setCurrency('XBT'); + $model = unserialize(serialize($model)); + $this->assertEquals('XBT', $model->currency); + + $model->meta = new EloquentTestStringifyableObject(['friends' => ['Adam']]); + $this->assertEquals('{"friends":["Adam"]}', $model->toArray()['meta']); + } + /** * Helpers... */ @@ -1365,3 +1408,97 @@ public function level() return $this->belongsTo(EloquentTestFriendLevel::class, 'friend_level_id'); } } + +class EloquentTestBase64Object +{ + public $secret; + + public function __construct($secret) + { + $this->secret = empty($secret) ? null : $secret; + } + + public static function fromHash($secret) + { + return new static(empty($secret) ? null : base64_decode($secret)); + } + + public function hash() + { + return empty($this->secret) ? null : base64_encode($this->secret); + } +} + +class EloquentTestStringifyableObject +{ + public $json; + + public function __construct($json) + { + $this->json = $json; + } + + public static function fromString($string) + { + return new static(json_encode($string)); + } + + public function __toString() + { + return json_encode($this->json); + } +} + +class EloquentTestMoneyObject +{ + public $amount; + public $currency; + + public function __construct($amount) + { + $this->amount = $amount; + } + + public function setCurrency($currency) + { + $this->currency = $currency; + + return $this; + } +} + +class EloquentTestObjectCast extends Eloquent +{ + protected $casts = [ + 'meta' => 'stringifyable', + 'token' => 'base64', + 'price' => 'money:price,currency', + ]; + + public function castToStringifyable($value) + { + return EloquentTestStringifyableObject::fromString($value); + } + + public function castToBase64($value) + { + return EloquentTestBase64Object::fromHash($value); + } + + public function castFromBase64($secret) + { + return $secret->hash(); + } + + public function castToMoney($amount, $currency) + { + return (new EloquentTestMoneyObject($amount))->setCurrency($currency); + } + + public function castFromMoney($money) + { + if ($money) { + return [$money->amount, $money->currency]; + } + } +}