laravel / ideas

Issues board used for Laravel internals discussions.
939 stars 28 forks source link

Dynamic polymorphic many-to-many relationships #1867

Open Jhnbrn90 opened 5 years ago

Jhnbrn90 commented 5 years ago

Introduction

If we take the current documentation on many-to-many polymorphic relationships, the example is given of a Tag which can have relations to a Post or/and a Video.

https://laravel.com/docs/6.x/eloquent-relationships#many-to-many-polymorphic-relations

A 'taggables' (pivot) table holds the relationship itself since we have access to the Tag via the "tag_id" column, and a "taggable" via the "taggable_id" and "taggable_type". The taggable will be morphed to an underlying model as specified in the Tag model:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model
{
    /**
     * Get all of the posts that are assigned this tag.
     */
    public function posts()
    {
        return $this->morphedByMany('App\Post', 'taggable');
    }

    /**
     * Get all of the videos that are assigned this tag.
     */
    public function videos()
    {
        return $this->morphedByMany('App\Video', 'taggable');
    }

A tag's associated posts and videos can then be retrieved using $tag->posts and $tag->videos respectively. And new entries are saved using $tag->posts()->save($post).

The problem

Now, let's assume we have potentially a lot of candidates for this polymorphic relationship (a lot of underlying classes) and we don't know which will be associated.

Rather than registering all those models with the Tag model, I'd rather use some dynamic association to be able to fetch all related models (e.g. via $tag->items) and also to save any model (e.g. via $tag->items()->save($podcast), where $podcast is an App\Podcast model).

I asked several experienced Laravel users how they would solve it, but it seems not trivial to do so. Therefore, I'm filing this issue here.

Please tell me if I'm overlooking something, because this seems like a trivial problem to me.

If it doesn't exist in Laravel yet, might it be a nice addition?

staudenmeir commented 5 years ago

Please tell me if I'm overlooking something, because this seems like a trivial problem to me.

It's far from trivial. You can easily write an items accessor that fetches all related models with separate queries, but it's a completely different challenge to create a real relationship for eager loading, pagination, etc.

I wrote a package that achieves this using SQL views and UNION queries: laravel-merged-relations The example shows that you still need to define an individual relationship for each polymorphic type, but you could get around that.

Jhnbrn90 commented 5 years ago

@staudenmeir thanks for your reply! I see that I didn't realize the complexity of this problem.

Actually, it started with this question on the Laracasts forum about polymorphic relationships. The user in this case only has three models to be associated, so I suggested to use the standard many-to-many polymorphic relationship approach.

Then, I thought (as an experiment): what if you would have a lot of underlying possible models that could be associated.

Instead of dynamic many-to-many polymorphic relationships, I've been thinking about an alternative approach which manually interacts with the pivot table. I think in most use cases the example as mentioned below is sufficiently effective. For the inverse relationship, Laravel's morphToMany() can be used.

Therefore, I think this solves the problem.

Definition of add(), remove(), and get() methods to allow interaction with multiple models:

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class Order extends Model
{
    public function addItem(Model $model)
    {
        DB::table('item_order')->insert([
            'order_id'     => $this->id,
            'item_id'      => $model->id,
            'item_type'    => get_class($model),
        ]);
    }

    public function removeItem(Model $model)
    {
        $order = DB::table('item_order')
            ->where('item_type', get_class($model))
            ->where('item_id', $model->id)
            ->first();

        if (! $order) {
            throw new \Exception
                ('Given model was not associated with this order.');
        }

        return DB::table('item_order')->delete($order->id);
    }

    public function getItems()
    {
        $orderItems = collect(
            DB::table('item_order')
                ->where('order_id', $this->id)->get()
            );

        return $orderItems->map(function ($orderItem) {
            return resolve($orderItem->item_type)::findOrFail($orderItem->item_id);
        });
    }
}

Definition of the inverse relationship:

class SomeItem extends Model
{
    public function orders()
    {
        return $this->morphToMany('App\Order', 'item', 'item_order');
    }
}