Skip to content

Commit

Permalink
Port object casting (laravel#13706) to master
Browse files Browse the repository at this point in the history
  • Loading branch information
tillkruss committed Mar 16, 2017
1 parent 182027d commit 86983ab
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 11 deletions.
131 changes: 120 additions & 11 deletions src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php
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,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)
);
}
}

Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -850,6 +957,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
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 @@ -1351,6 +1355,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
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($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,
];
}

0 comments on commit 86983ab

Please sign in to comment.