Laravel - Eager Loading Polymorphic Relation's Related Models
Asked Answered
F

5

38

I can eager load polymorphic relations/models without any n+1 issues. However, if I try to access a model related to the polymorphic model, the n+1 problem appears and I can't seem to find a fix. Here is the exact setup to see it locally:

1) DB table name/data

history

history table

companies

enter image description here

products

enter image description here

services

enter image description here

2) Models

// History
class History extends Eloquent {
    protected $table = 'history';

    public function historable(){
        return $this->morphTo();
    }
}

// Company
class Company extends Eloquent {
    protected $table = 'companies';

    // each company has many products
    public function products() {
        return $this->hasMany('Product');
    }

    // each company has many services
    public function services() {
        return $this->hasMany('Service');
    }
}

// Product
class Product extends Eloquent {
    // each product belongs to a company
    public function company() {
        return $this->belongsTo('Company');
    }

    public function history() {
        return $this->morphMany('History', 'historable');
    }
}

// Service
class Service extends Eloquent {
    // each service belongs to a company
    public function company() {
        return $this->belongsTo('Company');
    }

    public function history() {
        return $this->morphMany('History', 'historable');
    }
}

3) Routing

Route::get('/history', function(){
    $histories = History::with('historable')->get();
    return View::make('historyTemplate', compact('histories'));
});

4) Template with n+1 logged only becacuse of $history->historable->company->name, comment it out, n+1 goes away.. but we need that distant related company name:

@foreach($histories as $history)
    <p>
        <u>{{ $history->historable->company->name }}</u>
        {{ $history->historable->name }}: {{ $history->historable->status }}
    </p>
@endforeach
{{ dd(DB::getQueryLog()); }}

I need to be able to load the company names eagerly (in a single query) as it's a related model of the polymorphic relation models Product and Service. I’ve been working on this for days but can't find a solution. History::with('historable.company')->get() just ignores the company in historable.company. What would an efficient solution to this problem be?

Foothold answered 4/11, 2014 at 3:6 Comment(2)
you can eager load Company model dynamically by $historables->load('company')Kovacev
@Kovacev I added an update, where would your suggestion go to eager load Company?Foothold
A
78

Solution:

It is possible, if you add:

protected $with = ['company']; 

to both the Service and Product models. That way, the company relation is eager-loaded every time a Service or a Product is loaded, including when loaded via the polymorphic relation with History.


Explanation:

This will result in an additional 2 queries, one for Service and one for Product, i.e. one query for each historable_type. So your total number of queries—regardless of the number of results n—goes from m+1 (without eager-loading the distant company relation) to (m*2)+1, where m is the number of models linked by your polymorphic relation.


Optional:

The downside of this approach is that you will always eager-load the company relation on the Service and Product models. This may or may not be an issue, depending on the nature of your data. If this is a problem, you could use this trick to automatically eager-load company only when calling the polymorphic relation.

Add this to your History model:

public function getHistorableTypeAttribute($value)
{
    if (is_null($value)) return ($value); 
    return ($value.'WithCompany');
}

Now, when you load the historable polymorphic relation, Eloquent will look for the classes ServiceWithCompany and ProductWithCompany, rather than Service or Product. Then, create those classes, and set with inside them:

ProductWithCompany.php

class ProductWithCompany extends Product {
    protected $table = 'products';
    protected $with = ['company'];
}

ServiceWithCompany.php

class ServiceWithCompany extends Service {
    protected $table = 'services';
    protected $with = ['company'];
}

...and finally, you can remove protected $with = ['company']; from the base Service and Product classes.

A bit hacky, but it should work.

Abnaki answered 9/11, 2014 at 3:25 Comment(8)
The edit with extending the class made all the difference, as it calls it only when the polymorphic needs it, not everywhere everytime. Thanks for this great workaraound damiani. Since it extends the original class(es) we can remove $table from the extended class. I again hope Taylor allows us to query it easily like other relations like this $histories = History::with('historable.company')->get(); Thanks again damiani :)Foothold
Note that you can remove $table only if you declared it explicitly on the parent model; if you didn't, then the polymorphic call will be looking for the table product_with_company. This load-distant-relation functionality would be a useful addition to Laravel, though it's a bit complicated since it assumes that the same relation (company) is available on all morphed models.Abnaki
Yeah - of course it's explicitly stated in the parent model :) I requested it on github from Taylor - waiting to see his thoughts on this as it would be awesome to eager load it like other models. Specially if we explicitly call it, it should work if it exists, otherwise, throw an error. Would be a great addition to Laravel.Foothold
Just wondering about getHistorableTypeAttribute where did you discover this. I can't seem to find information about it. Any online resource(s)?Foothold
It's an accessor, and there's a very short description of them in the docs at laravel.com/docs/4.2/eloquent#accessors-and-mutators. It's a function that gets performed on an attribute (i.e. on a field from your database) whenever it's accessed; getFooAttribute will be performed whenever the foo property is accessed, and $this->foo will be set to whatever value is returned from the accessor.Abnaki
Pretty interesting, it's a buried gem. Thanks for pointing it out damiani :)Foothold
awesome! Just solved an issue with Polymorphic in L5.1!!Geilich
Genius, I never would have considered extending a third time!Nondisjunction
K
11

You can separate the collection, then lazy eager load each one:

$histories =  History::with('historable')->get();

$productCollection = new Illuminate\Database\Eloquent\Collection();
$serviceCollection = new Illuminate\Database\Eloquent\Collection();

foreach($histories as $history){
     if($history->historable instanceof Product)
          $productCollection->add($history->historable);
     if($history->historable instanceof Service)
        $serviceCollection->add($history->historable);
}
$productCollection->load('company');
$serviceCollection->load('company');

// then merge the two collection if you like
foreach ($serviceCollection as $service) {
     $productCollection->push($service);
}
$results = $productCollection;

Probably it's not the best solution, adding protected $with = ['company']; as suggested by @damiani is as good solution, but it depends on your business logic.

Kovacev answered 9/11, 2014 at 5:10 Comment(1)
Great answer Razor. This and damiani's answer are great workarounds and both work, but I like how damiani extracted it out as a class extension keeping the route/controller clean, so I'll go for his answer. I really wish Taylor Otwell is able to add the syntax to laravel and simply call it like this $histories = History::with('historable.company')->get(); Thanks for your great answer - upvoted!Foothold
H
5

Pull Request #13737 and #13741 fixed this issue.

Just update your Laravel version and the following code

protected $with = [‘likeable.owner’];

Will work as expected.

Hemato answered 30/5, 2016 at 12:8 Comment(0)
G
0

I'm not 100% sure on this, because it's hard to re-create your code in my system but perhaps belongTo('Company') should be morphedByMany('Company'). You could also try morphToMany. I was able to get a complex polymorphic relationship to load properly without multiple calls. ?

Grizel answered 7/11, 2014 at 22:6 Comment(3)
Not sure, I will put together more details early tomorrow so people can easy reproduce the issue locally, so it's easier to see it. It would be amazing to get it to work.Foothold
Not sure at all... morphToMany and morphedByMany are used in a many to many polymorphic relation, which is not the case and requires a different table structure.Bissell
Updated question to be easily reproducible.Foothold
S
-1

As João Guilherme mentioned, this was fixed in version 5.3 However, I've found myself facing the same bug in an App where it's not feasible to upgrade. So I've created an override method that will apply the fix to Legacy APIs. (Thanks João, for pointing me in the right direction to produce this.)

First, create your Override class:

namespace App\Overrides\Eloquent;

use Illuminate\Database\Eloquent\Relations\MorphTo as BaseMorphTo;

/**
 * Class MorphTo
 * @package App\Overrides\Eloquent
 */
class MorphTo extends BaseMorphTo
{
    /**
     * Laravel < 5.2 polymorphic relationships fail to adopt anything from the relationship except the table. Meaning if
     * the related model specifies a different database connection, or timestamp or deleted_at Constant definitions,
     * they get ignored and the query fails.  This was fixed as of Laravel v5.3.  This override applies that fix.
     *
     * Derived from https://github.com/laravel/framework/pull/13741/files and
     * https://github.com/laravel/framework/pull/13737/files.  And modified to cope with the absence of certain 5.3
     * helper functions.
     *
     * {@inheritdoc}
     */
    protected function getResultsByType($type)
    {
        $model = $this->createModelByType($type);
        $whereBindings = \Illuminate\Support\Arr::get($this->getQuery()->getQuery()->getRawBindings(), 'where', []);
        return $model->newQuery()->withoutGlobalScopes($this->getQuery()->removedScopes())
            ->mergeWheres($this->getQuery()->getQuery()->wheres, $whereBindings)
            ->with($this->getQuery()->getEagerLoads())
            ->whereIn($model->getTable().'.'.$model->getKeyName(), $this->gatherKeysByType($type))->get();
    }
}

Next, you'll need something that lets your Model classes actually talk to your incarnation of MorphTo rather than Eloquent's. This can be done by either a trait applied to each model, or a child of Illuminate\Database\Eloquent\Model that gets extended by your model classes instead of Illuminate\Database\Eloquent\Model directly. I chose to make this into a trait. But in case you chose to make it a child class, I've left in the part where it infers the name as a heads-up that that's something you'd need to consider:

<?php

namespace App\Overrides\Eloquent\Traits;

use Illuminate\Support\Str;
use App\Overrides\Eloquent\MorphTo;

/**
 * Intended for use inside classes that extend Illuminate\Database\Eloquent\Model
 *
 * Class MorphPatch
 * @package App\Overrides\Eloquent\Traits
 */
trait MorphPatch
{
    /**
     * The purpose of this override is just to call on the override for the MorphTo class, which contains a Laravel 5.3
     * fix.  Functionally, this is otherwise identical to the original method.
     *
     * {@inheritdoc}
     */
    public function morphTo($name = null, $type = null, $id = null)
    {
        //parent::morphTo similarly infers the name, but with a now-erroneous assumption of where in the stack to look.
        //So in case this App's version results in calling it, make sure we're explicit about the name here.
        if (is_null($name)) {
            $caller = last(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2));
            $name = Str::snake($caller['function']);
        }

        //If the app using this trait is already at Laravel 5.3 or higher, this override is not necessary.
        if (version_compare(app()::VERSION, '5.3', '>=')) {
            return parent::morphTo($name, $type, $id);
        }

        list($type, $id) = $this->getMorphs($name, $type, $id);

        if (empty($class = $this->$type)) {
            return new MorphTo($this->newQuery(), $this, $id, null, $type, $name);
        }

        $instance = new $this->getActualClassNameForMorph($class);
        return new MorphTo($instance->newQuery(), $this, $id, $instance->getKeyName(), $type, $name);
    }
}
Stiver answered 27/2, 2017 at 15:2 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.