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.8] Pivot models provided by using() should use model methods for write operations #27571

Merged
merged 8 commits into from
Feb 26, 2019

Conversation

ralphschindler
Copy link
Contributor

@ralphschindler ralphschindler commented Feb 18, 2019

Overview

The driving use case behind this PR is to allow Pivot tables/classes/models to be able to trigger model events, which are currently not possible since InteractsWithPivotTable does not go through the Eloquent model.

Changes and tests have been provided.

Details

This PR adds additional support to InteractsWithPivotTable (used by Relations\BelongsToMany|MorphToMany) such that write operations to a pivot table (specifically: attach, detach, updateExistingPivot) will use the defined $this->belongsToMany()->using(MyPivot::class) when performing these operations. By using the Pivot (through AsPivot trait methods) as an eloquent model, you gain the ability to utilize the eloquent lifecycle events when Pivot's are saved/deleted.

(Sidenote: I believe there are other use cases for supporting Pivot tables at true Models, I feel like there is a use case for treating Pivot's as first class Models inside Nova.)

Example

In a situation where there exist a Category, Post, and a Pivots\CategoryPost inside an application:

namespace App\Models {
  class Category extends Model
  {
    public function posts()
    {
      return $this->belongsToMany(Post::class);
    }
  }

  class Post extends Model
  {
    public function categories()
    {
      return $this->belongsToMany(Category::class)->using(Pivots\CategoryPost::class);
    }
  }
}

namespace App\Models\Pivots {
  class CategoryPost extends Pivot
  {
    public static function boot()
    {
      parent::boot();

      static::saving(function (Model $model) {
        echo "In [{$model->getMorphClass()}|{$model->getQueueableId()}] - SAVING\n";
      });

      static::saved(function (Model $model) {
        echo "In [{$model->getMorphClass()}|{$model->getQueueableId()}] - SAVED\n";
      });

      static::deleting(function (Model $model) {
        echo "IN [{$model->getMorphClass()}|{$model->getQueueableId()}] - DELETING\n";
      });

      static::deleted(function (Model $model) {
        echo "IN [{$model->getMorphClass()}|{$model->getQueueableId()}] - DELETED\n";
      });
    }

    public function setOrderAttribute($value)
    {
      $this->attributes['order'] = ((int) $value) * 10;
    }
  }
}

A sync method might look like, and its output:

$category1 = Models\Category::create(['name' => 'One']);
$category2 = Models\Category::create(['name' => 'Two']);
$category3 = Models\Category::create(['name' => 'Three']);
$post = new Models\Post(['name' => 'My Post']);
$post->save();
$post->categories()->sync([$category1->id => ['order' => 1], $category2->id => ['order' => 2]]);
$post->categories()->sync([$category3->id => ['order' => 5], $category1->id => ['order' => 9]]);

// In [App\Models\Pivots\CategoryPost|post_id:1:category_id:1] - SAVING
// In [App\Models\Pivots\CategoryPost|post_id:1:category_id:1] - SAVED
// In [App\Models\Pivots\CategoryPost|post_id:1:category_id:2] - SAVING
// In [App\Models\Pivots\CategoryPost|post_id:1:category_id:2] - SAVED
// IN [App\Models\Pivots\CategoryPost|post_id:1:category_id:2] - DELETING
// IN [App\Models\Pivots\CategoryPost|post_id:1:category_id:2] - DELETED
// In [App\Models\Pivots\CategoryPost|post_id:1:category_id:3] - SAVING
// In [App\Models\Pivots\CategoryPost|post_id:1:category_id:3] - SAVED
// In [App\Models\Pivots\CategoryPost|post_id:1:category_id:1] - SAVING
// In [App\Models\Pivots\CategoryPost|post_id:1:category_id:1] - SAVED

Inspiration & Research

https://github.com/fico7489/laravel-pivot
https://gitlab.com/altek/eventually/
https://github.com/spatie/laravel-activitylog
Laravel Issue: #8452
https://medium.com/@codebyjeff/custom-pivot-table-models-or-choosing-the-right-technique-in-laravel-fe435ce4e27e
https://github.com/signifly/laravel-pivot-events
https://stackoverflow.com/questions/46358388/laravel-5-5-events-not-fired-on-pivot

@ralphschindler
Copy link
Contributor Author

ralphschindler commented Feb 25, 2019

Seems like the clock is ticking on 5.8 😄 , is there anything more I can do to chauffeur this along? I think many might find this PR useful for similar situations to ours: needing a comprehensive way to audit changes (ie: audit trails, audit logging) to data that flows through Eloquent (including changes to pivot/junction tables). Thanks again!

@taylorotwell
Copy link
Member

taylorotwell commented Feb 25, 2019

Thinking through this. Is there any implication to setting that incrementing property to false on Pivot models when NOT using a custom pivot class?

My initial thinking is "no" since we were never really using the custom models when performing any insert / update / delete operations on Pivots. If the user iterated over their pivot models to update some values and called save I don't think the incrementing matters at that point since ID is already assigned.

@taylorotwell
Copy link
Member

taylorotwell commented Feb 25, 2019

It seems the main breaking change with this PR is if people are using a custom pivot model and were also using an incrementing primary key on that model (I believe Laravel Nova itself does this actually - nevermind, it doesn't), they would need to update their custom pivot models to set incrementing to true.

Are there any other breaking changes here?

@ralphschindler
Copy link
Contributor Author

It is entirely possible that change can be backed out... I could write a test to capture the scenario of "someone extended a pivot, is using() it, and it has primary key / autoinc instead of a compound primary key" (which is what Laravel generally wants to you have, pivots without id's).

Separately, Pivot or AsPivot could have a flag (instead of checking using()) that would make the InteractsWithPivotTable use the Pivot::save()/Pivot::delete(), that would make it completely backwards compatible and fully opt-in.

(I read your comment in laravel/ideas that basically said Eloquent was going to move towards using Pivot (Eloquent save/delete) methods anyway, instead of crafting the SQL to run the write operations with). laravel/ideas#953 (comment)

That would mean someone who wanted the Pivot to use its own save/delete might write code that looks like this:

namespace App\Models\Pivots;

use Illuminate\Database\Eloquent\Relations\Pivot;

class FooBar extends Pivot
{
    public $usePivotSaveDelete = true;
}

Then perhaps this becomes the default in 6.0?

@taylorotwell
Copy link
Member

But, is that the only breaking change on this PR that you are aware of?

@taylorotwell taylorotwell merged commit 0ba72ab into laravel:5.8 Feb 26, 2019
@taylorotwell
Copy link
Member

Eh, there is another breaking change that may make me back this out for now... it's basically impossible to do a batch detach now when using a custom pivot model unless I'm mistaken. In a 5.8 application I have, I was doing this:

image

My tests began failing after this PR and it's because obviously I can't chain query conditions on a detach anymore when using a custom pivot model.

@taylorotwell
Copy link
Member

Eh, disregard previous code example... but I do wonder if there is a problem here when using wherePivot and wherePivotIn when performing a detach operation with a custom pivot class.

@erikgaal
Copy link
Contributor

@taylorotwell

Eh, disregard previous code example... but I do wonder if there is a problem here when using wherePivot and wherePivotIn when performing a detach operation with a custom pivot class.

Just observed this exact behaviour in our app. Our app can detach Role from User based on some pivot data. A user can have multiple roles within different contexts. However, since this change, the following did not work anymore as it would detach all related models instead of those matching the wherePivot's.

// This would ignore the context_id
$user->roles()->wherePivot('context_id', 'some-id')
    ->detach('some-role-id');

// This is the workaround
$user->roles()->wherePivot('context_id', 'some-id')
    ->wherePivot('role_id', 'some-role-id')
    ->detach();

@driesvints
Copy link
Member

@erikgaal please open an issue if this PR caused a bug.

@staudenmeir
Copy link
Contributor

@erikgaal detach() has been fixed in #27997.

@lk77
Copy link

lk77 commented May 3, 2019

Hello,

it seems that since this PR, sync is taking into account the soft delete on pivot models.
When deleting pivot records, they are soft deleted now and not hard deleted.

But the soft delete is not taken into account when reading data from pivot models, they still appear,
which means that relations are undeletable now, if using SoftDeletes Trait. For me it's inconsistent, if it's taking into account soft-delete when deleting, it should also take it into account when reading.

Thanks.

@driesvints
Copy link
Member

@lk77 please open up a new issue with a detailed explanation what you're experiencing. Fill out the issue template as well please.

@staudenmeir
Copy link
Contributor

There are so many issues with this PR, I think we should consider reverting or moving it to 5.9.

@taylorotwell
Copy link
Member

@staudenmeir what does "so many issues" mean? Specifically how many do you think. Can you list them so we can determine if should fix them or revert? /cc @ralphschindler

@taylorotwell
Copy link
Member

@ralphschindler are you able to help us with any of these?

@taylorotwell
Copy link
Member

@themsaid has fixed #28150 now. We will be looking at the others this week.

@themsaid
Copy link
Member

themsaid commented May 6, 2019

I don't believe #28407 is a bug. Also #28025 isn't actually a bug, not sure why you shared it as a bug @staudenmeir.

@AdrianKuriata
Copy link

AdrianKuriata commented May 17, 2019

@staudenmeir @taylorotwell @themsaid Hello, I tried to detach all elements attached to the model, but with no effect. I have User and Group model connected with belongsToMany relation, in the User model I have groups method:

public function groups()
{
    return $this->belongsToMany(Group::class)->using(GroupUser::class);
}

In the Pivot GroupUser class I have a boot static method for listening to events:

public static function boot()
{
    parent::boot();

    static::deleting(function (Model $model) {
        logger('Detaching group or groups');
    });
}

And code, which I call:

$user->groups()->detach(); // It is not work

$user->groups()->detach(1); // Works ok

$user->groups()->detach([1, 2]); // Works well too

My question is, do you in the short future make it working without adding any parameter to the detach() method? Problem is with deleting event for the Pivot class which is not called when you don't put any parameter to the detach() method.

Sync not working too.

@staudenmeir
Copy link
Contributor

@themsaid I called #28025 an issue, not a bug.

@AkramNahim
Copy link

Hi ralphschindler, i have almost the same situation here . I'm right now working on a project where i want to implement a 3-Air relationship ( Student , Exam, Subject) linking these tree tables, give us a Fourth table 'Mark'. Now what i want the user (Teacher), after navigating to a particular subject and choosing an exam, is being able to enter each student mark and by saving it these marks, it get to be stored in the appropriate table which is 'Mark' , knowing this table is referenced by (Subj_id, Exam_id, Student_id), please give me some advice on how apprehending the issue and if using a 'View Model' based view will help solving the problem
Thank you

LukeTowers added a commit to wintercms/storm that referenced this pull request Jan 24, 2022
Pivot::fromAttributes() instead of new Pivot(): laravel/framework@06f01cf & laravel/framework@4b30aad

(protected) Pivot()->parent -> (public) Pivot()->pivotParent: laravel/framework@29a181f & laravel/framework@22c3c02

$otherKey -> $relatedKey: laravel/framework@d2a7777

Added fromRawAttributes() method to Pivot: laravel/framework@f356419

Various improvements to setKeysForSaveQuery(): laravel/framework@8d82618, laravel/framework@e9f37ed

Fire model events when deleting Pivot models: laravel/framework#27571

Fixed Pivot serialization: laravel/framework@b52d314 & laravel/framework#31956
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.

10 participants