Skip to content
28 changes: 28 additions & 0 deletions src/Illuminate/Contracts/Database/Eloquent/CastsAttributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Illuminate\Contracts\Database\Eloquent;

interface CastsAttributes
{
/**
* Transform the attribute from the underlying model values.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return mixed
*/
public function get($model, string $key, $value, array $attributes);

/**
* Transform the attribute to its underlying model values.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return array
*/
public function set($model, string $key, $value, array $attributes);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace Illuminate\Contracts\Database\Eloquent;

interface CastsInboundAttributes
{
/**
* Transform the attribute to its underlying model values.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return array
*/
public function set($model, string $key, $value, array $attributes);
}
189 changes: 177 additions & 12 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Carbon\CarbonInterface;
use DateTimeInterface;
use Illuminate\Contracts\Database\Eloquent\CastsInboundAttributes;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\JsonEncodingException;
use Illuminate\Database\Eloquent\Relations\Relation;
Expand Down Expand Up @@ -44,6 +45,38 @@ trait HasAttributes
*/
protected $casts = [];

/**
* The attributes that have been cast using custom classes.
*
* @var array
*/
protected $classCastCache = [];

/**
* The built-in, primitive cast types supported by Eloquent.
*
* @var array
*/
protected static $primitiveCastTypes = [
'array',
'bool',
'boolean',
'collection',
'custom_datetime',
'date',
'datetime',
'decimal',
'double',
'float',
'int',
'integer',
'json',
'object',
'real',
'string',
'timestamp',
];

/**
* The attributes that should be mutated to dates.
*
Expand Down Expand Up @@ -173,7 +206,9 @@ protected function addMutatedAttributesToArray(array $attributes, array $mutated
protected function addCastAttributesToArray(array $attributes, array $mutatedAttributes)
{
foreach ($this->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;
}

Expand Down Expand Up @@ -211,7 +246,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 @@ -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);
}

Expand Down Expand Up @@ -352,7 +388,7 @@ public function getAttributeValue($key)
*/
protected function getAttributeFromArray($key)
{
return $this->attributes[$key] ?? null;
return $this->getAttributes()[$key] ?? null;
}

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

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -926,13 +1024,76 @@ 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.
*
* @return array
*/
public function getAttributes()
{
$this->mergeAttributesFromClassCasts();

return $this->attributes;
}

Expand All @@ -951,6 +1112,8 @@ public function setRawAttributes(array $attributes, $sync = false)
$this->syncOriginal();
}

$this->classCastCache = [];

return $this;
}

Expand Down Expand Up @@ -998,7 +1161,7 @@ public function only($attributes)
*/
public function syncOriginal()
{
$this->original = $this->attributes;
$this->original = $this->getAttributes();

return $this;
}
Expand All @@ -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;
Expand Down
20 changes: 19 additions & 1 deletion src/Illuminate/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
*
Expand Down
Loading