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

[5.6] Disable model relationship touching #23469

Merged
merged 18 commits into from
Jun 20, 2018
Merged

[5.6] Disable model relationship touching #23469

merged 18 commits into from
Jun 20, 2018

Conversation

tonysm
Copy link
Contributor

@tonysm tonysm commented Mar 9, 2018

Added

  • Add the ability to disable relationship model touching functionality for a given callback scope
  • Add Model::isIgnoringTouch() method to check if the model is in the withoutTouching scope

TL;DR

We can now disable the relationship model touching in a given callback lifetime for specific models when we want to with the new Model::withoutTouching and Model::withoutTouchingOn methods.


I saw this functionality in one of DHH's videos (here) and figured I would give it a try to implement in Laravel. Not sure if you find it useful or not, I was mainly interested in what it would take to write it.

ActiveRecord documentation can be found here, for reference.

Examples

Let's say a Post belongs to some User and has the user relationship set in the touches array.

use Illuminate\Database\Eloquent\Model;

Model::withoutTouching(function () use ($user, $post) {
    $user->touch(); # updates user timestamps.
    $post->touch(); # updates post, but not the related models thru relationships.

    // Updates the model name and timestamps. However, It will not update 
    // any relationships timestamps that are in the no touching target.
    $post->update(['name' => 'Lorem']);
});

User::withoutTouching(function () use ($user, $post) {
    $user->touch(); # updates user timestamps.
    $post->touch(); # updates timestamp, but not the related user's

    // Updates post timestamps, but not the related user's.
    $post->update(['name' => 'Lorem']);
});

// You can also nest calls
User::withoutTouching(function () use ($user, $post) {
    Post::withoutTouching(function () use ($user, $post) {
        $user->touch(); # updates user timestamps.
        $post->touch(); # updates post, but not the related models thru relationships.

        // Updates the model name and timestamps. However, It will not update 
        // any relationships timestamps that are in the no touching target.
        $post->update(['name' => 'Lorem']);
    });
});

// Alternatively, you can avoid the nesting by specifying the models you want to ignore at once:
Model::withoutTouchingOn([User::class, Post::class], function () use ($user, $post) {
    $user->touch(); # updates user timestamps.
    $post->touch(); # updates post, but not the related models thru relationships.

    // Updates the model name and timestamps. However, It will not update 
    // any relationships timestamps that are in the no touching target.
    $post->update(['name' => 'Lorem']);
});

// Check if model is in the `withoutTouching` scope
$this->assertFalse(Model::isIgnoringTouch());
$this->assertFalse(User::isIgnoringTouch());

Model::withoutTouching(function () {
    $this->assertTrue(Model::isIgnoringTouch());
    $this->assertTrue(User::isIgnoringTouch());
});

User::withoutTouching(function () {
    $this->assertFalse(Model::isIgnoringTouch());
    $this->assertTrue(User::isIgnoringTouch());
});

$this->assertFalse(Model::isIgnoringTouch());
$this->assertFalse(User::isIgnoringTouch());

As I'm not that familiar with the Eloquent internals, I'm not entirely sure about the current implementation.

@tonysm tonysm changed the title Disable model touching [5.6] Disable model touching Mar 9, 2018
@Miguel-Serejo
Copy link

I get wanting to override $touches in some cases, but explicitly calling ->touch() should never just silently do nothing. It should either throw an exception (TouchedUntouchableModelException or similar) or actually touch.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 10, 2018

Yeah, I also had that feeling. At first I was implementing it only for the relationship touching, but looking at the Rails API, looks like they do both.

@taylorotwell
Copy link
Member

What is this used for?

@tonysm
Copy link
Contributor Author

tonysm commented Mar 10, 2018

When you use the touching feature of Eloquent and you are doing some batch operation dealing with lots of models at the same time (like the incineration feature in Basecamp - deleting lots of data), the touching might generate lots of unnecessary queries, and could potentially slow down the whole process.

But, this is not a use case I've encountered myself, I just saw it on Rails and got curious about what it would take to bring it to Eloquent.

@dwightwatson
Copy link
Contributor

Would like to see something like this go in. I've had to use $model->timestamps = false as a hack to get around it in the past.

My use-case is an app that shows updated timestamps to users to they can see how recently activity has occurred. We have background jobs and a moderation team that make changes to these models, but these changes aren't public-facing so we don't want it to appear as though the record has been updated to other users.

Being able to wrap it up in a withoutTouching callback would make it clearer and show more intent than having to use the $model->timestamps = false approach which isn't as obvious.

@taylorotwell
Copy link
Member

taylorotwell commented Mar 12, 2018

@tonysm so were there more changes you wanted to make based on @36864's comments? I didn't quite follow.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 12, 2018

so were there more changes you wanted to make based on @36864's comments? I didn't quite follow.

Depends on how you want it to behave. Right now, in the withoutTouching scope:

  • Touching timestamps through relationships when a model is updated is disabled; and
  • Direct calls to $model->touch() are also disabled

The comment was about the last option, whether it should be a noop, throw an exception, or not disabling at all.

I think the Rails API for this feature is as I implemented, by looking at their API and documentation. I'm gonna try to run a small experiment here to confirm that. But let me know what you think.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 12, 2018

@taylorotwell just confirmed the way it's implemented right now matches the Rails API. So if you are ok with it, nothing else needs to be done. Unless you can think of any side effects that I need to take into account.

Below you can find how Rails implements it.

irb(main):014:0> p = Post.first
  Post Load (0.6ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<Post id: 1, title: "Lorem", user_id: 1, created_at: "2018-03-12 15:27:45", updated_at: "2018-03-12 15:27:45">
irb(main):015:0> p.title = "Lorem ipsum"
=> "Lorem ipsum"
irb(main):016:0> p.save
   (0.2ms)  begin transaction
  User Load (0.4ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  SQL (1.0ms)  UPDATE "posts" SET "title" = ?, "updated_at" = ? WHERE "posts"."id" = ?  [["title", "Lorem ipsum"], ["updated_at", "2018-03-12 15:28:35.939259"], ["id", 1]]
  SQL (0.5ms)  UPDATE "users" SET "updated_at" = '2018-03-12 15:28:35.946283' WHERE "users"."id" = ?  [["id", 1]]
   (18.1ms)  commit transaction
=> true
irb(main):017:0> User.no_touching do
irb(main):018:1*   p.title = "Relationship touching"
irb(main):019:1>   p.save
irb(main):020:1> end
   (0.2ms)  begin transaction
  SQL (0.9ms)  UPDATE "posts" SET "title" = ?, "updated_at" = ? WHERE "posts"."id" = ?  [["title", "Relationship touching"], ["updated_at", "2018-03-12 15:29:09.085786"], ["id", 1]]
   (8.6ms)  commit transaction
=> true
irb(main):021:0> User.first.touch
  User Load (0.4ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
   (0.2ms)  begin transaction
  SQL (0.7ms)  UPDATE "users" SET "updated_at" = '2018-03-12 15:29:19.736664' WHERE "users"."id" = ?  [["id", 1]]
   (9.7ms)  commit transaction
=> true
irb(main):022:0> User.no_touching do
irb(main):023:1*   User.first.touch
irb(main):024:1> end
  User Load (0.6ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> nil

@tonysm
Copy link
Contributor Author

tonysm commented Mar 12, 2018

@dwightwatson

Being able to wrap it up in a withoutTouching callback would make it clearer and show more intent than having to use the $model->timestamps = false approach which isn't as obvious.

Actually, I don't think it fits your usecase quite well. You're updating the model itself, which will update the timetamps of the model, for example:

>>> $p = Post::create(['title' => 'Lorem', 'user_id' => 1])
"insert into "posts" ("title", "user_id", "updated_at", "created_at") values (?, ?, ?, ?)"
"update "users" set "updated_at" = ? where "users"."id" = ?"
"select * from "users" where "users"."id" = ? limit 1"
>>> Post::withoutTouching(function () use ($p) {
         $p->update(['title' => 'Something else']);
    });
"update "posts" set "title" = ?, "updated_at" = ? where "id" = ?"
"update "users" set "updated_at" = ? where "users"."id" = ?"

As you can see, the model itself got the updated_at field touched. That's also true for the Rails API

irb(main):001:0> p = Post.first
  Post Load (0.3ms)  SELECT  "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT ?  [["LIMIT", 1]]
irb(main):003:0> Post.no_touching do
irb(main):004:1*     p.update title: "Lorem"
irb(main):005:1> end
   (0.2ms)  begin transaction
  SQL (0.8ms)  UPDATE "posts" SET "title" = ?, "updated_at" = ? WHERE "posts"."id" = ?  [["title", "Lorem"], ["updated_at", "2018-03-12 16:00:45.502981"], ["id", 1]]
  SQL (0.6ms)  UPDATE "users" SET "updated_at" = '2018-03-12 16:00:45.507653' WHERE "users"."id" = ?  [["id", 1]]
   (8.9ms)  commit transaction
=> true

But that being said, I think we should not update the model's timestamps in the withoutTouching scope. What do you think @taylorotwell ?

@unstoppablecarl
Copy link
Contributor

Is the idea to disable all touching for all models? Or just specific models?

@tonysm
Copy link
Contributor Author

tonysm commented Mar 12, 2018

@unstoppablecarl both. You can disable specific models touching or globally.

specific:

User::withoutTouching(function () use ($post) {
    // creates the comment without updating related user from post, but post is touched.
    $post->comments()->create(...); 

    Post::withoutTouching(function () use ($post) {
         // creates the comment without touching the post or the user 
         // related to the post (because of the nested scope).
         $post->comments()->create(...);
    });
});

globally:

use Illuminate\Database\Eloquent\Model;

Model::withoutTouching(function () use ($post) {
    // creates the comment without touching any relationship
    $post->comments()->create(...);
});

@tonysm
Copy link
Contributor Author

tonysm commented Mar 12, 2018

@dwightwatson I was wrong. In my implementation, we are not touching the model's timestamp, I had a cached revision here in my testing application. So calls to $model->update() do not update the updated_at field when the model class is in the withoutTouching target.

Example:

>>> $p = Post::first()
"select * from "posts" limit 1"
=> App\Post {#761
     id: "1",
     title: "lorem",
     user_id: "1",
     created_at: "2018-03-12 15:55:19",
     updated_at: "2018-03-12 16:25:34",
   }
>>> $p->update(['title' => 'ipsum']);                                                                                                                                                         "update "posts" set "title" = ?, "updated_at" = ? where "id" = ?"
"update "users" set "updated_at" = ? where "users"."id" = ?"
"select * from "users" where "users"."id" = ? limit 1"
=> true
>>> Post::withoutTouching(function () use ($p) {                                                                                                                                                
      $p->update(['title' => 'lorem']);                                                                                                                                                           
    });
"update "posts" set "title" = ? where "id" = ?"
"update "users" set "updated_at" = ? where "users"."id" = ?"
=> null

But this differs from the Rails implementation. (see the Rails implementation int he comment above #23469 (comment)).

Any thoughts on whether we should stick to the Rails API and update the model's updated_at when calling $model->update(), or stick to the current implementation and just update the dirty fields, skiping the timestamps (again, when in then withoutTouching scope)?

@taylorotwell
Copy link
Member

If it's going to be the same method name as Rails probably it would need to behave the same way so as not to surprise people.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 13, 2018

@taylorotwell Done. Don't think there's anything else to do here. Going to send another PR to cover the usecase @dwightwatson mentioned later today.

@afraca
Copy link
Contributor

afraca commented Mar 14, 2018

Nitpick, but any particular reason you use self:: over static::? I've had to override properties like those on the fly myself and would find the self:: here a bit annoying.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 14, 2018

@afraca good point, I thought I had to use self, but turns out it was not the case.

@tonysm tonysm changed the title [5.6] Disable model touching [5.6] Disable model relationship touching Mar 15, 2018
@unstoppablecarl
Copy link
Contributor

Can you call model::withoutTouching if Model is abstract?

@unstoppablecarl
Copy link
Contributor

unstoppablecarl commented Mar 17, 2018

I really like this idea. I think this may be a better api for it though.

Model::withoutTouching([Foo::class, Bar::class, ...], function(){}); where the array is all model classes you do not want touched.

Foo::withoutTouchingRelations(['posts', 'users'], function(){}); where the array are relations of the foo model.

I have had some cases where I needed to conditionally touch some models in the collection of related models but not others. How would you write that?

@JosephSilber
Copy link
Contributor

If it's going to be the same method name as Rails probably it would need to behave the same way so as not to surprise people.

Is that true for every other method in Laravel as well? I'm a little surprised by this opinion.

Regarding the general feature: I feel quite strongly that calling touch directly should definitely touch the model. To me, it doesn't make mush sense to disable a direct call to touch.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 26, 2018

Regarding the general feature: I feel quite strongly that calling touch directly should definitely touch the model. To me, it doesn't make mush sense to disable a direct call to touch.

@JosephSilber I think you are right. I thought of introducing a forceTouch method instead, but makes sense that directly calling $model->touch() bypasses the protection. Was trying to mimic the Rails API, but it does make more sense than introducing a new method.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 26, 2018

@unstoppablecarl

Can you call model::withoutTouching if Model is abstract?

The implementation is based on instanceof, so if you do:

class ParentModel extends Model {}

class ChildModel extends ParentModel {}

ParentModel::withoutTouching(function () {
   // If something triggers updating timestamps of child model relationships,
   // it won't touch it because ChildModel instanceof ParentModel === true.
});

Model::withoutTouching([Foo::class, Bar::class, ...], function(){}); where the array is all model classes you do not want touched.

I like it. Wish PHP had method overloading. Anyways, so for this API it should not include the class being class? Like, ignore the caller, and use only the given classes?

Foo::withoutTouchingRelations(['posts', 'users'], function(){}); where the array are relations of the foo model.

Not sure about this one. It's doable, but don't think it's that useful, tbh. It would only make it much more complex that it should be, IMO. It should just be "if you encounter relationships of this class, don't touch it".

I have had some cases where I needed to conditionally touch some models in the collection of related models but not others. How would you write that?

Can you provide an example? If I got it right, you want to conditionally touch models based on some criteria. Like, if you encounter relationships of class "User" and the ID is 123, don't touch it, if not this ID it's ok to touch it. Can't think of a use case where you are using the relationship touching feature, but you don't want to touch specific models of a hasMany relationship.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 26, 2018

Gonna make some changes here:

  • withoutTouching scope should only be for relationship model touching. We should not ignore direct calls to $model->touch();
  • Will add a new withoutTouchingOn([A::class, B::class], function () {}) API method to avoid nested calls of withoutTouching, but should behave exactly the same as nested calls.

@tonysm
Copy link
Contributor Author

tonysm commented Mar 27, 2018

I think it's all done here.

static::$ignoreOnTouch = array_values(array_merge(static::$ignoreOnTouch, $models));

try {
call_user_func($callback);
Copy link
Contributor

Choose a reason for hiding this comment

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

Is call_user_func($callback) better than $callback() ?

public function shouldTouch()
{
foreach (static::$ignoreOnTouch as $ignoredClass) {
if ($this instanceof $ignoredClass) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this could cause some surprise to users that extend models. I think it would be better to check if(get_class($this) === $ignoredClass)

Choose a reason for hiding this comment

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

I would disagree, I would be surprised if extending an ignored class causes the child class to not be ignored.
Also, this allows you to have a parent "IgnoredModel" class and extend all your models from that instead of having to explicitly ignore every model.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this also allows us to globally ignore relationship touching by invoking it from the base model class. That's the desired behavior.

Copy link
Contributor

@olivernybroe olivernybroe left a comment

Choose a reason for hiding this comment

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

Maybe add a getter for $ignoreOnTouch?

@unstoppablecarl
Copy link
Contributor

Maybe add a getter for $ignoreOnTouch?

I like that. maybe isIgnoringTouch() ?

@taylorotwell
Copy link
Member

@tonysm where are we at with this?

@tonysm
Copy link
Contributor Author

tonysm commented May 21, 2018

@olivernybroe @unstoppablecarl Added the Model::isIgnoringTouch() method.

@taylorotwell It's ready

@taylorotwell
Copy link
Member

Just curious, do we have actual use cases for this waiting for this to be merged? Like packages being developed, etc. Or is this mainly something be done "in theory"? 😄

@tonysm
Copy link
Contributor Author

tonysm commented May 23, 2018

All in theory. I haven't seen anyone needing it in the Laravel world. As I said:

this is not a use case I've encountered myself, I just saw it on Rails and got curious about what it would take to bring it to Eloquent.

Can be closed, no big deal. haha

@negoziator
Copy link

negoziator commented Jun 2, 2018

We do have a use case.
In our company, we are migrating millions of rows to our Laravel application within months.

We have been running into a performance problem, where touching of related models is actually a problem! We have hacked ourself around it, but this change would really be appreciated.

@taylorotwell taylorotwell merged commit 1d85125 into laravel:5.6 Jun 20, 2018
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.

9 participants