Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 121 additions & 11 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand All @@ -194,7 +215,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 @@ -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);
}

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

Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -485,8 +513,61 @@ 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->attributes, $key, $this
);
}
}

/**
* 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->attributes, $key, $this)
);
}
}

Expand Down Expand Up @@ -537,11 +618,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->attributes, $key, $this)
));

unset($this->classCastCache[$key]);
} else {
$this->classCastCache[$key] = $value;
}
}

/**
* Determine if a set mutator exists for an attribute.
*
Expand Down Expand Up @@ -850,6 +958,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 @@ -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
Expand Down Expand Up @@ -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.');
}
Expand Down Expand Up @@ -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.
*
Expand Down
71 changes: 71 additions & 0 deletions tests/Database/DatabaseEloquentIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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...
*/
Expand Down Expand Up @@ -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($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,
];
}