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
148 changes: 133 additions & 15 deletions src/Illuminate/Database/Eloquent/Model.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab
*/
protected $casts = [];

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

/**
* The relationships that should be touched on save.
*
Expand Down Expand Up @@ -252,6 +259,16 @@ abstract class Model implements ArrayAccess, Arrayable, Jsonable, JsonSerializab
*/
public static $manyMethods = ['belongsToMany', 'morphToMany', 'morphedByMany'];

/**
* All of the valid primitive cast types.
*
* @var array
*/
protected static $primitiveCastTypes = [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Laravel 5.3 is PHP 5.6, you could make this a CONST. Do note that HHVM 3.3 doesn't support this though. I'm not sure about later versions.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scratch my last comment. I was referring to define. :/

'int', 'integer', 'real', 'float', 'double', 'string', 'bool', 'boolean',
'object', 'array', 'json', 'collection', 'date', 'datetime', 'timestamp',
];

/**
* The name of the "created at" column.
*
Expand Down Expand Up @@ -1097,6 +1114,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 @@ -1448,6 +1467,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 @@ -2461,7 +2482,8 @@ public function attributesToArray()
// will not perform the cast on those attributes to avoid any confusion.
foreach ($this->getCasts() as $key => $value) {
if (! array_key_exists($key, $attributes) ||
in_array($key, $mutatedAttributes)) {
in_array($key, $mutatedAttributes) ||
$this->isClassCastable($key)) {
continue;
}

Expand Down Expand Up @@ -2491,7 +2513,7 @@ public function attributesToArray()
*/
protected function getArrayableAttributes()
{
return $this->getArrayableItems($this->attributes);
return $this->getArrayableItems($this->getAttributes());
}

/**
Expand Down Expand Up @@ -2587,7 +2609,7 @@ protected function getArrayableItems(array $values)
*/
public function getAttribute($key)
{
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 @@ -2659,8 +2681,8 @@ public function getRelationValue($key)
*/
protected function getAttributeFromArray($key)
{
if (array_key_exists($key, $this->attributes)) {
return $this->attributes[$key];
if (array_key_exists($key, $attributes = $this->getAttributes())) {
return $attributes[$key];
}
}

Expand Down Expand Up @@ -2718,7 +2740,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 Down Expand Up @@ -2777,6 +2803,37 @@ protected function isJsonCastable($key)
return $this->hasCast($key, ['array', 'json', 'object', 'collection']);
}

/**
* 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)
);
}
}

/**
* Get the type of cast for a model attribute.
*
Expand All @@ -2797,11 +2854,13 @@ protected function getCastType($key)
*/
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 @@ -2826,8 +2885,29 @@ 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
);
}
}

Expand Down Expand Up @@ -2856,15 +2936,37 @@ public function setAttribute($key, $value)
$value = $this->fromDateTime($value);
}

if ($this->isJsonCastable($key) && ! is_null($value)) {
$value = $this->asJson($value);
if ($this->isClassCastable($key)) {
$this->setClassCastableAttribute($key, $value);
} elseif ($this->isJsonCastable($key) && ! is_null($value)) {
$this->attributes[$key] = $this->asJson($value);
} else {
$this->attributes[$key] = $value;
}

$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 @@ -3023,12 +3125,14 @@ public function fromJson($value, $asObject = false)
*/
public function replicate(array $except = null)
{
$except = $except ?: [
$defaults = [
$this->getKeyName(),
$this->getCreatedAtColumn(),
$this->getUpdatedAtColumn(),
];

$except = $except ? array_unique(array_merge($except, $defaults)) : $defaults;

$attributes = Arr::except($this->attributes, $except);

$instance = new static;
Expand All @@ -3045,6 +3149,8 @@ public function replicate(array $except = null)
*/
public function getAttributes()
{
$this->mergeAttributesFromClassCasts();

return $this->attributes;
}

Expand Down Expand Up @@ -3513,6 +3619,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
63 changes: 63 additions & 0 deletions tests/Database/DatabaseEloquentIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,39 @@ public function testForPageAfterIdCorrectlyPaginates()
$this->assertEquals(1, count($results));
}

public function testAttributesMayBeCastToValueObjects()
{
$model = new EloquentTestValueObjectCast;
$model->line_one = 'Address Line 1';
$model->line_two = 'Address Line 2';

$this->assertInstanceOf('EloquentTestAddressValueObject', $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 @@ -937,3 +970,33 @@ class EloquentTestUserWithStringCastId extends EloquentTestUser
'id' => 'string',
];
}

class EloquentTestAddressValueObject implements Illuminate\Contracts\Support\Arrayable {
public $lineOne, $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',
];
}