Skip to content
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

[10.x] Use model cast when builder created updated at value #47942

Merged
merged 4 commits into from Aug 10, 2023

Conversation

timacdonald
Copy link
Member

@timacdonald timacdonald commented Aug 3, 2023

fixes #47769

If a model is using a cast on the updated_at column, it is not respected when the eloquent builder creates the updated_at value.

<?php

namespace App\Models;

use App\Casts\UnixTimeStampToCarbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Customer extends Model
{
    use HasFactory;

    protected $casts = [
        'created_at' => UnixTimeStampToCarbon::class,
        'updated_at' => UnixTimeStampToCarbon::class,
    ];
}

When updating a model, in most cases the updated_at value is created in the eloquent model, where the cast is respected as expected.

When the model creates the value the builder receives the updated_at value in the array it is asked to put into the database. When this happens, everything is good.

When the value of updated_at has not changed, i.e. the time is the same as last time it was updated - which you might find happens in a unit test - the updated_at is not marked as "dirty" and the model does not pass the updated_at column through to the builder.

In this case, the builder manually creates and adds the updated_at value to the array it is putting into the database.

When the builder creates the updated_at value it does not use any casts that are in place on the model for the updated_at column.

For timestamp only values, if you are storing the updated_at as an integer, as per #47769, and not other dates, the value attempted to be insert into the database will be 2020-01-01 00:00:00 formatted and MySQL will throw an exception as it is expecting an integer.

Performance implications

I ran some benchmarks using 10,000 iterations.

Updated at set by the builder, i.e. time has not progressed

If you do not have a cast in place for the updated_at column: there is no change ☑️

If you do have a cast in place for the updated_at column: 0.006ms > 0.02ms ⬇️

Updated at set by the model, i.e. time has progressed

0.006ms > 0.0ms (micro optimisation) ⬆️

So the implications are only for the cases where you have a cast in place and time has not progressed, which I would guess is 99.999999999999% of the time in a unit test and not going to impact an actual user.

@willpower232
Copy link

Makes sense to me and has @driesvints vote too #47769 (comment)

Do you need me to patch my code and confirm its all hunky dory?

@timacdonald
Copy link
Member Author

@willpower232 that would be super. The test I added replicates the exception and scenario you were hitting, so either way I think this PR makes sense.

However, I would absolutely love for you to actually test this. Let's mark as draft until you can confirm.

@timacdonald timacdonald marked this pull request as draft August 3, 2023 22:55
Comment on lines 1142 to 1154
if (! array_key_exists($column, $values)) {
$timestamp = $this->model->freshTimestampString();

if (
$this->model->hasSetMutator($column)
|| $this->model->hasAttributeSetMutator($column)
|| $this->model->hasCast($column)
) {
$timestamp = $this->model->newInstance([$column => $timestamp])->getAttributes()[$column];
}

$values = array_merge([$column => $timestamp], $values);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

The outer array_key_exists check is a micro optimisation here, as per the performance section of the description. It can be safely removed if we don't want it.

@timacdonald
Copy link
Member Author

Updated to fix for mutators / attributes

@willpower232
Copy link

I swapped your repo and branch in for the framework folder in a testing copy and got the following error from running tests

Undefined array key "updated_at" (expand for stack trace)
Undefined array key "updated_at"

/var/www/vendor/laravel/framework/src/Illuminate/Testing/TestResponse.php:148
/var/www/tests/Feature/Http/Controllers/CustomerControllerTest.php:164
/var/www/vendor/laravel/framework/src/Illuminate/Foundation/Testing/TestCase.php:174

3) Tests\Feature\Http\Controllers\UserControllerTest::testUpdateUserRoles
Expected response status code [201, 301, 302, 303, 307, 308] but received 500.
Failed asserting that false is true.

The following exception occurred during the last request:

ErrorException: Undefined array key "updated_at" in /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:1150
Stack trace:
#0 /var/www/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php(254): Illuminate\Foundation\Bootstrap\HandleExceptions->handleError(2, 'Undefined array...', '/var/www/vendor...', 1150)
#1 /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php(1150): Illuminate\Foundation\Bootstrap\HandleExceptions->Illuminate\Foundation\Bootstrap\{closure}(2, 'Undefined array...', '/var/www/vendor...', 1150)
#2 /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php(1042): Illuminate\Database\Eloquent\Builder->addUpdatedAtColumn(Array)
#3 /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php(1213): Illuminate\Database\Eloquent\Builder->update(Array)
#4 /var/www/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php(1130): Illuminate\Database\Eloquent\Model->performUpdate(Object(Illuminate\Database\Eloquent\Builder))
#5 /var/www/app/Http/Controllers/UserController.php(45): Illuminate\Database\Eloquent\Model->save()
<further lines removed for brevity>
$timestamp = $this->model->newInstance([$column => $timestamp])->getAttributes()[$column];

I presume this means that the attribute isn't returned by getAttributes for some reason? Am I missing a change to the cast class we have?

@timacdonald
Copy link
Member Author

@willpower232 give it another shot, if you don't mind. Didn't account for attribute guarding.

@willpower232
Copy link

Aha that seems to be correct, its passing all my tests now 🎉

image

@timacdonald timacdonald marked this pull request as ready for review August 9, 2023 22:19
@taylorotwell taylorotwell merged commit 19b4209 into laravel:10.x Aug 10, 2023
20 checks passed
@timacdonald
Copy link
Member Author

Thanks for testing this, @willpower232

@timacdonald timacdonald deleted the updated-at branch August 10, 2023 23:20
@willpower232
Copy link

No problemo, thanks for figuring it all out!

@maxxscho
Copy link

@timacdonald I have a mutator on the updated_at column which prevents setting updated_at on creating a model. If I update a model I get the following error:

ErrorException: Undefined array key "updated_at"
../vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:254
../vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:1167
../vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php:1057
../vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:1213
../vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php:1130

Creating a models works as expected. Do you have any idea?

@willpower232
Copy link

@maxxscho are you able to share your mutator code?

@timacdonald
Copy link
Member Author

I’m very sorry, I didn’t see a notification for your message @maxxscho

As @willpower232 asked, if we could please see the mutator we can take a look. It might be good to open an issue and provide the mutator so I can take a look.

Mention me in the issue and I will jump in.

@maxxscho
Copy link

@willpower232 sure.

public function setUpdatedAtAttribute($value): void
{
    if (! $this->id) {
        return;
    }

    $this->updated_at = $value;
}

Basically only if the model has an ID (it has been persisted in the DB) the updated_at attribute should get a value (this peace of the software is very old so it might look a little bit odd 😄 )

@willpower232
Copy link

Your idea makes sense but shouldn't it be

$this->attributes['updated_at'] = $value;

@timacdonald
Copy link
Member Author

We can likely resolve the issue with

$timestamp = $this->model->newInstance()
    ->forceFill([$column => $timestamp])
    ->getAttributes()[$column] ?? $timestamp;

Funnily enough I actually had this in the original version but removed it cause I couldn’t think of when it would happen - but now I know!

We need to add a test to validate the fix and ensure it doesn’t break again in the future.

I’m on my phone and not in till tomorrow.

will ping @jbrooksuk @driesvints to see if they have time to PR a test with the above fix for the Tuesday release 🙏

@timacdonald
Copy link
Member Author

The ?? $timestamp is for here.

CDF23347-8776-4B6D-A186-AC7A484C4761

@maxxscho
Copy link

@timacdonald I quickly tried this fix and my tests are green again.

@driesvints
Copy link
Member

@maxxscho I'm having a hard time reproducing this in a test. Can you share your full model please?

@maxxscho
Copy link

@driesvints of course.

<?php

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;

class UserRunStreak extends BaseModel
{
    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'user_id',
        'current',
        'record',
        'updated_at',
    ];

    protected $casts = [
        'current' => 'integer',
        'record' => 'integer',
    ];

    /**
     * Set updated_at attribute only on updates, not create.
     */
    public function setUpdatedAtAttribute($value): void
    {
        if (! $this->id) {
            return;
        }

        $this->updated_at = $value;
    }

    /**
     * Increase current.
     */
    public function increaseCurrent(): void
    {
        $this->current += 1;
        $this->save();
    }

    /**
     * Increase record.
     */
    public function increaseRecord(): void
    {
        $this->record += 1;
        $this->save();
    }

    /**
     * Reset current.
     */
    public function resetCurrent(): void
    {
        $this->current = 0;
        $this->save();
    }

    /**
     * Scope query to filter active results.
     */
    public function scopeNotUpdatedFor24hours(Builder $query): Builder
    {
        return $query->where('updated_at', '<', Carbon::now()->subDay()->toDateTimeString());
    }
}

@driesvints
Copy link
Member

@maxxscho do you somehow manually set the updated_at when creating a model?

@maxxscho
Copy link

@driesvints no, it's not set manually on creating. The reason for the mutator is to keep updated_at null on creating.

@maxxscho
Copy link

@driesvints a little bit off-topic:
I'd love to help on this issue but I never contributed to Laravel. I forked the repo, cloned it, installed via composer install and tried to run the tests but all MySQL tests are skipped with the message Test requires a MySQL connection. I didn't find any documentation how to set up MySQL for the tests. Currently I don't have that much time - will take a look after the work into this.
Or is there a simple step I missed? (Sorry to bother you with this 🙂 )

@driesvints
Copy link
Member

I just have too few info to reproduce this sorry. Can you create a repo for me with an app that reproduces this?

laravel new bug-report --github="--public"

@driesvints
Copy link
Member

Nvm, got it: #48230. Thanks

@maxxscho
Copy link

@driesvints and @timacdonald thx for this quick help 🚀

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

Successfully merging this pull request may close these issues.

Cast on Updated At column not applied for rapid saves (i.e. unit tests)
5 participants