Skip to content
Closed
165 changes: 155 additions & 10 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand All @@ -201,7 +212,7 @@ protected function addCastAttributesToArray(array $attributes, array $mutatedAtt
*/
protected function getArrayableAttributes()
{
return $this->getArrayableItems($this->attributes);
return $this->getArrayableItems($this->getAttributes());
}

/**
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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];
}
}

Expand Down Expand Up @@ -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;
}
Expand All @@ -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':
Expand All @@ -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)
);
}
}
}

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down Expand Up @@ -856,6 +999,8 @@ protected function isJsonCastable($key)
*/
public function getAttributes()
{
$this->mergeAttributesFromClassCasts();

return $this->attributes;
}

Expand Down
16 changes: 16 additions & 0 deletions src/Illuminate/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -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.
*
Expand Down
Loading