Skip to content
This repository has been archived by the owner on Jul 16, 2021. It is now read-only.

[Proposal] Implement custom & nested casting for Eloquent Model (WIP) #1354

Closed
arcanedev-maroc opened this issue Oct 12, 2018 · 4 comments
Closed

Comments

@arcanedev-maroc
Copy link

arcanedev-maroc commented Oct 12, 2018

I had some cases when i need to cast some attributes to a custom type or even with nested casts.

So, i've been thinkering with an easy and useable way to acheive this.

THE ZONDA IDEA

This is the feature we love to have in our daily life:

<?php namespace App\Models;

use App\Models\Casts\SettingProperties;
use Illuminate\Database\Eloquent\Model;

/**
 * @property-read  \App\Models\Casts\SettingProperties  properties
 */
class Setting extends Model
{
    protected $fillable = ['properties'];

    protected $casts = [
        'properties' => SettingProperties::class
    ];
}

And i have the properties column in the settings table with the following value.

{
    "has_zonda": 1, 
    "acquired_at": {
        "date": "2011-06-09 12:00:00.000000", 
        "timezone": "UTC"
    }
}

So we can access them like the following example:

$setting->properties->has_zonda;
$setting->properties['has_zonda'];
$setting->properties->acquired_at;
$setting->properties['acquired_at'];

And if you're asking what it looks like the SettingProperties ?

<?php namespace App\Models\Casts;

/**
 * @property-read  boolean                     has_zonda
 * @property-read  \Illuminate\Support\Carbon  acquired_at
 */
class SettingProperties extends AttributeCaster
{
    protected $casts  = [
        'has_zonda'   => 'boolean',
        'acquired_at' => Carbon::class,
    ];

    protected $selfCast = 'fluent';
}

The Carbon class is also an attribute caster, but it will allows you to handle the custom casts:

<?php namespace App\Models\Casts;

use Illuminate\Support\Carbon as IlluminateCarbon;

class Carbon extends AttributeCaster
{
    public function handle($value)
    {
        return new IlluminateCarbon($value['date'], $value['timezone']);
    }
}

About the AttributeCaster class, it's still WIP:

<?php namespace App\Models\Casts;

use Illuminate\Support\Fluent;

/**
 * We can refactor this to a new Trait or something similar.
 * Something like `Illuminate\Database\Eloquent\Concerns\CastAttributes` and use it inside HasAttributes
 */
abstract class AttributeCaster
{
    protected $casts = [];

    protected $selfCast = null; // or 'fluent', 'collection

    public function handle($value)
    {
        $value = (new static)->castAttributes($value);

        return $this->castSelfAttribute($value);
    }

    protected function castAttributes($values)
    {
        $data = is_array($values) ? $values : json_decode($values, true);

        if ( ! empty($this->casts)) {
            foreach ($this->casts as $key => $type) {
                $data[$key] = $this->castAttribute($type, $data[$key]);
            }
        }

        return $data;
    }

    protected function castAttribute($type, $value)
    {
        if (is_null($value))
            return $value;

        if (
            array_key_exists($type, $this->casts) &&
            is_a($type, self::class, true) // Replace it with an interface ?!
        ) {
            return (new $type)->handle($value);
        }

        switch ($type) {
            // Same casts as default (integer, bool, json ...)
            case 'boolean':
            case 'bool':
                return boolval($value);

            // ...

            default:
                return $value;
        }
    }

    protected function castSelfAttribute($value)
    {
        switch ($this->selfCast) {
            case 'fluent':
                return new Fluent($value);

            case 'collection':
                return collect($value);

            default:
                return $value;
        }
    }
}

To Reproduce

This is the full Setting model class with overridden default cast attribute:

<?php namespace App\Models;

use App\Models\Casts\AttributeCaster;
use App\Models\Casts\SettingProperties;
use Illuminate\Database\Eloquent\Model;

/**
 * @property-read  \App\Models\Casts\SettingProperties  properties
 */
class Setting extends Model
{
    protected $fillable = ['properties'];

    protected $casts = [
        'properties' => SettingProperties::class
    ];

    /**
     * Overridden method
     */
    public function castAttribute($key, $value)
    {
        if (is_null($value)) {
            return $value;
        }

        if (
            array_key_exists($key, $this->casts) &&
            is_a($this->casts[$key], AttributeCaster::class, true)
        ) {
            return (new $this->casts[$key])->handle($value);
        }

        return parent::castAttribute($key, $value);
    }
}

And create/copy the other classes with their respective directories/namespaces:

  • App\Models\Casts\AttributeCaster
  • App\Models\Casts\SettingProperties
  • App\Models\Casts\Carbon

And finally in your web.php routes:

Route::get('test', function () {
    $setting = new \App\Models\Setting([
        'properties' => '{"has_zonda": 1, "acquired_at": {"date": "2011-06-09 12:00:00.000000", "timezone": "UTC"}}'
    ]);

    dd(
        $setting->toArray(),
        $setting->properties->has_zonda,
        $setting->properties['has_zonda'],
        $setting->properties->acquired_at,
        $setting->properties['acquired_at']
    );
});

Related:

@mfn
Copy link

mfn commented Oct 13, 2018

@sisve
Copy link

sisve commented Oct 14, 2018

Keep in mind that casts can theoretically be two-way, and we should at least make sure that our method names will not cause issues in the future. You either cast a database value to a php value, or a php value to a database value. In this case, why are you assigning a string to the properties attribute when creating a Setting, instead of assigning an instance of SettingProperties?

$user = new User(array(
    'address' => new Address('Test Street', 'Zipcode', 'City')
));

In my fantasy world (where the sun always shines and I'm not always bitter), that would be capable of setting the address_street, address_zipcode and address_city database fields. And reading the user from the database would combine these three columns into one Address object.

@arcanedev-maroc
Copy link
Author

Hi @sisve,

I've started a new repo (https://github.com/ARCANEDEV/LaravelCastable) to see how far we can extend the casts feature.

Any feedbacks/suggestions would be greatly appreciated 👍

NOTE: If this suggestion been rejected to be in the laravel/framework, i'll release it as a laravel package

@Gummibeer
Copy link

Gummibeer commented Aug 21, 2019

I would like to evolve this to a more general approach.
I bet that most of us know how to it feels to create multiple accessor/mutator methods only for the same thing because we have multiple price attributes or whatever in the app.

So it would be awesome to be able to register custom casts which implement an accessor and mutator and replace the methods in the model itself by only registering them like a driver and using them in the $casts property like the default/core ones.

Model::registerCastDriver('coordinates', new CoordinateAttributeCaster());

$casts = [
    'location' => 'coordinates',
];

This would also make it easier for packages to provide casts.
Like:

Without messing with model core methods and forcing conflicts with multiple custom casts.

In best case this also allows to pass additional information like the decimal or date cast.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants