Skip to content

[7.x] Custom Cast Types + Object / Value Object Casts #31035

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jan 5, 2020
Merged

Conversation

taylorotwell
Copy link
Member

@taylorotwell taylorotwell commented Jan 5, 2020

This adds the ability to write your own custom casts. These can cast to simple primitive values like strings (like an encrypted string) or to objects. When casting to objects, you have the opportunity to sync the object's values back into the model before saving.

Objects that cast attributes must implement the Illuminate\Contracts\Database\Eloquent\CastsAttributes contract or CastsInboundAttributes. This CastsAttributes interface defines a get and set method.

For example, a JSON caster that replicates the built-in JSON casting offered by Laravel would look like the following:

<?php

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class JsonCaster implements CastsAttributes
{
    public function get($model, $key, $value, $attributes)
    {
        return json_decode($value, true);
    }

    public function set($model, $key, $value, $attributes)
    {
        return json_encode($value);
    }
}

A caster that casts to a value object with multiple properties may look like the following:

<?php

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class AddressCaster implements CastsAttributes
{
    public function get($model, $key, $value, $attributes)
    {
        return new Address(
            $attributes['address_line_one'], 
            $attributes['address_line_two']
        );
    }

    public function set($model, $key, $value, $attributes)
    {
        return [
            'address_line_one' => $value->lineOne, 
            'address_line_two' => $value->lineTwo
        ];
    }
}

When defined this way, you may update the address object properties and then save the model. The object's properties will be synced back to the model before saving:

$user->address->lineOne = 'New Line 1';
$user->save();

@GrahamCampbell GrahamCampbell changed the title Custom Cast Types + Object / Value Object Casts [7.x] Custom Cast Types + Object / Value Object Casts Jan 5, 2020
@taylorotwell taylorotwell merged commit 051932e into master Jan 5, 2020
@taylorotwell taylorotwell deleted the casts branch January 5, 2020 22:37
@andrey-helldar
Copy link
Contributor

In my opinion, its implementation requires refinement: 7befbb6#diff-b50af0acefc58e2c0d89569159ab38d5R33-R36

@taylorotwell, Why should a developer keep different key names in mind? This is not obvious.

2020-01-08_18-57-14

address_line_one !== address->lineOne

The solution proposed in #30958 PR covers this problem.

@browner12
Copy link
Contributor

it's only that way because that's how it was defined in the Custom Caster. you can use whatever names you'd like.

@andrey-helldar
Copy link
Contributor

@browner12, ok, see: 33d7f2f#diff-b50af0acefc58e2c0d89569159ab38d5R45-R48

$model->address = null;

$this->assertNull($model->toArray()['address_line_one']);
$this->assertNull($model->toArray()['address_line_two']);

I don’t understand where the relationship between the various attributes is: $model->address and $model->address_line_one?

But what if I need to store different values in a model? For example:

[
  'address_city' => 'Country, City',
  'address_street' => 'Country, City, Street',
  'address' => [
    'city' => 'City',
    'street' => 'Street',
  ]
]

?

With the current code, this will cause an error.

@andrey-helldar
Copy link
Contributor

In general, I have a lot of questions about the functionality of this code.

@devcircus
Copy link
Contributor

I'm sure the functionality will be fully documented by the release of version 7.

@andrey-helldar
Copy link
Contributor

andrey-helldar commented Jan 8, 2020

Documented but not working ;)

And I will prove it!

We take the tests/Integration/Database/DatabaseEloquentModelCustomCastingTest.php test as a basis and simply add the following to it:

class DatabaseEloquentModelCustomCastingTest extends DatabaseTestCase
{
   public function testStringCastable()
    {
        $this->migrate();
        $this->createModel();
    }

    protected function migrate()
    {
        Schema::create('test_model', function (Blueprint $table) {
            $table->increments('id');
            $table->string('field_1')->nullable();
            $table->decimal('field_2')->nullable();
            $table->json('field_3')->nullable();
        });
    }

    protected function createModel()
    {
        return TestModel::create([
            'field_1' => ['f', 'o', 'o', 'b', 'a', 'r'],
            'field_2' => 20,
            'field_3' => [
                'line_one' => 'Address Line 1',
                'line_two' => 'Address Line 2',
            ],
        ]);
    }
}

class TestModel extends Model
{
    public $table = 'test_model';

    public $timestamps = false;

    public $casts = [
        'field_1' => StringCast::class,
        'field_2' => NumberCast::class,
        'field_6' => AddressCast::class,
    ];

    protected $guarded = ['id'];
}

class StringCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes)
    {
        return str_split($value);
    }

    public function set($model, string $key, $value, array $attributes)
    {
        return implode('', $value);
    }
}

class NumberCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes)
    {
        return $value / 100;
    }

    public function set($model, string $key, $value, array $attributes)
    {
        return $value;
    }
}

class AddressCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes)
    {
        return new AddressCustom($value);
    }

    public function set($model, string $key, $value, array $attributes)
    {
        return $value;
    }
}

class AddressCustom
{
    public function __construct($value = null)
    {
        if (! is_array($value) && ! is_object($value)) {
            return;
        }

        foreach ($value as $key => $val) {
            $this->$key = $val;
        }
    }

    public function toArray()
    {
        return get_object_vars($this);
    }
}

And I get an error when running the test:

Illuminate\Database\QueryException : SQLSTATE[HY000]: General error: 1 table test_model has
no column named line_one (SQL: insert into "test_model" ("field_1", "field_2", "line_one",
"line_two") values (foobar, 20, Address Line 1, Address Line 2))

@andrey-helldar
Copy link
Contributor

andrey-helldar commented Jan 8, 2020

@browner12, @devcircus, I am surprised that a more workable version has already been created and proposed (#30958), but for some reason, Taylor chose to return to the incomplete worker, but his code was three years old (#13706).

This is upsetting. And after this, less and less I want to try to make something new.

I agree, Taylor knows best what to do with his product. But in our country this is called "invent a bicycle".

@browner12
Copy link
Contributor

As we mentioned in that PR, there have been many many other attempts at this feature in the past, so it's not surprising that Taylor picked his own attempt over selecting one of the others.

This feature has a lot of caveats, and that's the reason it's been put off so many times in the past.

Let's be happy this feature got in, and work on making it better as we go and learn.

@andrey-helldar
Copy link
Contributor

@browner12, Man, a work tool for this has already been created. There he is - #30958

@browner12
Copy link
Contributor

didn't you "invent a bicycle", since Taylor had done it already? 🤔

@andrey-helldar
Copy link
Contributor

andrey-helldar commented Jan 8, 2020

@browner12, you are mistaken. A bicycle can be called a working analogue code which already exists. In the case of Taylor, his code is not working. See my easy test above. In doing so, my code covers this functionality.

What Taylor did is called the invention of the bicycle, since its original code is not working. And now he copy-pasted it.

@andrey-helldar
Copy link
Contributor

@browner12, Further, Taylor used the get and set methods. In my stake, they already discussed why you should not do this (#30958 (comment)).

Spoiler: it is not clear which method is responsible for writing to the database, and which is for reading from it. And also which method is responsible for setting the attribute.

@devcircus
Copy link
Contributor

As this is a brand new feature on an unreleased branch, any bugs or weird behavior will be worked out before it is released. Also, as we've seen my attempts at this over the years, I have to assume there is a reason they went this direction, and like the rest of this amazing framework, all will work well when it is released. There's no reason, at this time to be stressed about something that isn't clear or that doesn't work how you expect. It will be worked out in the coming weeks and fully documented.

@andrey-helldar
Copy link
Contributor

@devcircus, anyway, I don’t understand why to invent and modify something at a time when there is already a proposed working solution (#30958)?..

@taylorotwell
Copy link
Member Author

@andrey-helldar of course your test doesn't work because you didn't even use the Cast properly. Your address cast should be returning:

return ['field_3' => $value];

You're becoming toxic in this thread. I didn't like your code. I didn't merge it. I didn't think it was very good. I merged my code which I liked better. The end.

@laravel laravel locked as off-topic and limited conversation to collaborators Jan 9, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants