16

I am using Laravel 5.1 and I need to limit the number of related records I am pulling using a polymorphic many-to-many relationship.

What I would like to do is get a list of categories by parent_id. For each category then I'd like to only pull four posts.

I have have this working with the code below, but it results in a bunch of extra queries. I'd like to just hit the database once if possible. I'd like to use the Laravel/Eloquent framework if at all possible, but am open to whatever works at this point.

@foreach ($categories as $category)
  @if ($category->posts->count() > 0)
    <h2>{{ $category->name }}</h2>
    <a href="/style/{{ $category->slug }}">See more</a>

    {-- This part is the wonky part --}

    @foreach ($category->posts()->take(4)->get() as $post)

      {{ $post->body }}

    @endforeach

  @endif
@endforeach

PostsController

public function index(Category $category)
{
  $categories = $category->with('posts')
      ->whereParentId(2)
      ->get();

  return view('posts.index')->with(compact('categories'));
}

Database

posts
    id - integer
    body - string

categories
    id - integer
    name - string
    parent_id - integer

categorizables
    category_id - integer
    categorizable_id - integer
    categorizable_type - string

Post Model

<?php namespace App;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
    public function categories()
    {
        return $this->morphToMany('App\Category', 'categorizable');
    }

Category Model

<?php namespace App;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
    public function category()
    {
        return $this->belongsTo('App\Category', 'parent_id');
    }
    public function subcategories()
    {
        return $this->hasMany('App\Category', 'parent_id')->orderBy('order', 'asc');
    }
    public function posts()
    {
        return $this->morphedByMany('App\Post', 'categorizable');
    }

I have seen a number of links to this on the web, but nothing that has actually worked for me.

I have tried this solution without any luck.

$categories = $category->with('posts')
->whereParentId(2)
->posts()
->take(4)
->get();

I have looked into this solution by Jarek at SoftOnTheSofa, but it is for a hasMany relationship and to be honest is a bit beyond my sql skill for me to adapt it for my needs.

Edit

I added a github repo for this setup, if it is useful to anybody.

Community
  • 1
  • 1
whoacowboy
  • 6,982
  • 6
  • 44
  • 78
  • @TaylorOtwell Any ideas? – whoacowboy Jun 29 '15 at 21:28
  • @jarek-tkaczyk Any ideas? – whoacowboy Jun 29 '15 at 21:28
  • @matt-stauffer Any ideas? – whoacowboy Jun 29 '15 at 21:28
  • I think your main problem is that this won't be able to be done with eager loading. As I understand it eager loading actually does two queries - one to get all your results, and one to get all the relations. So `Cats::with('posts')->all();` will do ``SELECT * FROM `cats``` and then gather all the IDs retrieved from that query and then craft a new one ``SELECT * FROM `posts` WHERE `cat_id` IN ([long list of IDs])``, it'll then go through that result set and split the models out into the parents they belong to. Obviously that's for a BT but I think the same thing follows for any relationship. – alexrussell Jul 01 '15 at 10:19
  • As such, putting any `->take()` will limit the total result set to 4 and not do what you want. You're going to have to do this manually. You can, however, use Laravel's own query builder to basically do a raw SQL query without the ugliness of a raw query. Is that acceptable to you or do you need to use Eloquent's magic to achieve what you want? – alexrussell Jul 01 '15 at 10:20
  • @alexrussell I was hoping to use Eloquent only because there are a number of other related models that would make a raw SQL a little more tedious. Since posting this I have come up with a raw SQL query that handles exactly this, but it doesn't get the other related models. – whoacowboy Jul 01 '15 at 15:38

6 Answers6

6

Edit your Category model

public function fourPosts() {
    // This is your relation object
    return $this->morphedByMany('App\Post', 'categorizable')
    // We will join the same instance of it and will add a temporary incrementing
    // Number to each post
    ->leftJoin(\DB::raw('(' . $this->morphedByMany('App\Post', 'categorizable')->select(\DB::raw('*,@post_rank := IF(@current_category = category_id, @post_rank + 1, 1) AS post_rank, @current_category := category_id'))
    ->whereRaw("categorizable_type = 'App\\\\Post'")
    ->orderBy('category_id', 'ASC')->toSql() . ') as joined'), function($query) {
        $query->on('posts.id', '=', 'joined.id');
    // And at the end we will get only posts with post rank of 4 or below
    })->where('post_rank', '<=', 4);
}

Then in your controller all categories you get with this

$categories = Category::whereParentId(1)->with('fourPosts')->get();

Will have only four posts. To test this do (remember that now you will load your posts with fourPosts method, so you have to access the four posts with this property):

foreach ($categories as $category) {
    echo 'Category ' . $category->id . ' has ' . count($category->fourPosts) . ' posts<br/>';
}

In short you add a subquery to the morph object that allows us to assign temporary incrementing number for each post in category. Then you can just get the rows that have this temporary number less or equal to 4 :)

Sh1d0w
  • 9,340
  • 3
  • 24
  • 35
  • Thanks for the answer. This errs on the leftJoin. Also does this take into account the many-to-many relationship? – whoacowboy Jul 02 '15 at 21:24
  • 2
    @whoacowboy Ok I used your git repo (very useful, we need more OPs as you) and did some tests on my local env. Please see my edited answer it works just perfect :) – Sh1d0w Jul 03 '15 at 05:00
  • 1
    @whoacowboy And yes all relations will be ok, we modify only the query that pulls the posts, not actual objects. Please let me know if this works for you, will be very glad to know the feedback. – Sh1d0w Jul 03 '15 at 05:08
4

Does this work for you?:

$categories = $category->with(['posts' => function($query)
    {
        $query->take(4);
    })
    ->whereParentId(2)
    ->get();
Hkan
  • 3,243
  • 2
  • 22
  • 27
  • Unfortunately this limits the query to 4 total posts and not 4 posts per category, but thanks. – whoacowboy Jun 27 '15 at 15:52
  • I guess there's nothing to do but query each category's posts in a separated SQL. You might overcome the overhead on MySQL by caching the posts with a 5 min expiration time. – Hkan Jun 27 '15 at 19:08
4

I think the most cross-DBMS way to do this would be using union all. Maybe something like this:

public function index(Category $category)
{
    $categories = $category->whereParentId(2)->get();

    $query = null;

    foreach($categories as $category) {
        $subquery = Post::select('*', DB::raw("$category->id as category_id"))
            ->whereHas('categories', function($q) use ($category) {
                $q->where('id', $category->id);
            })->take(4);

        if (!$query) {
            $query = $subquery;
            continue;
        }

        $query->unionAll($subquery->getQuery());
    }

    $posts = $query->get()->groupBy('category_id');

    foreach ($categories as $category) {
        $categoryPosts = isset($posts[$category->id]) ? $posts[$category->id] : collect([]);
        $category->setRelation('posts', $categoryPosts);
    }

    return view('posts.index')->with(compact('categories'));
}

And then you'd be able to loop through the categories and their posts in the view. Not necessarily the nicest looking solution but it would cut it down to 2 queries. Cutting it down to 1 query would probably require using window functions (row_number(), in particular), which MySQL doesn't support without some tricks to emulate it (More on that here.). I'd be glad to be proven wrong, though.

Community
  • 1
  • 1
dtaub
  • 160
  • 1
  • 7
  • This looks like it may work, but you have this set up for a hasMany relationship. Let me see if I can get it to work. – whoacowboy Jun 28 '15 at 14:02
  • No luck getting it to work although I think you have something in your approach. – whoacowboy Jun 29 '15 at 02:58
  • I'm curious, what's the issue with it? I haven't had a chance to actually set up the tables and really test it but the concept seems sound to me. – dtaub Jun 29 '15 at 13:48
  • The two main issues are that the posts table doesn't have a category_id column and the unionAll isn't picking up a database table name. I set up a github repo for this question, with your suggestions integrated. https://github.com/whoacowboy/example-laravel-many-to-many – whoacowboy Jun 29 '15 at 16:23
  • Ah right, I kind of overlooked that and messed up the unionAll. I edited it for the many-to-many relationship and fixed the unionAll. I think it should work now. – dtaub Jun 30 '15 at 02:10
  • Thanks for doing all that. Unfortunately it is still giving me and error. `General error: 1 LIMIT clause should come after UNION ALL not before` and seems to be err at this line `$posts = $query->get()->groupBy('category_id');` – whoacowboy Jun 30 '15 at 02:27
  • Looks like that's because Eloquent doesn't wrap the unioned queries in parentheses (although looking at the code and [this pull request](https://github.com/laravel/framework/issues/2494) it looks like it should for MySQL). I think there's not much you can do in that case aside from either getting that changed in Eloquent, or dropping down to raw SQL, in which case something like Rick James's answer below (potentially combined with some manipulations to get it into an object format) might be a better solution. – dtaub Jun 30 '15 at 13:52
1

Here's a "half answer". The half I give you is to show you the MySQL code. The half I can't give you is translating it into Laravel.

Here's an example of finding the 3 most populous cities in each province of Canada:

SELECT
    province, n, city, population
FROM
  ( SELECT  @prev := '', @n := 0 ) init
JOIN
  ( SELECT  @n := if(province != @prev, 1, @n + 1) AS n,
            @prev := province,
            province, city, population
        FROM  Canada
        ORDER BY
            province   ASC,
            population DESC
  ) x
WHERE  n <= 3
ORDER BY  province, n;

More details and explanation are found in my blog on groupwise-max.

Rick James
  • 135,179
  • 13
  • 127
  • 222
0

I think the best option (without using raw queries) would be to setup a custom method to be used with the eager loading.

So in the Category model you could do something like:

<?php

public function my_custom_method()
{
    return $this->morphedByMany('App\Post', 'categorizable')->take(4);
}

And then use that method when eager loading:

<?php

App\Category::with('my_custom_method')->get();

Make sure to use my_custom_method() instead of posts() in your view:

$category->my_custom_method()
Jocke Med Kniven
  • 3,909
  • 1
  • 22
  • 23
0

You can try this one, that work for me!

$projects = Project::with(['todo'])->orderBy('id', 'asc')->get()->map(function($sql) {
            return $sql->setRelation('todo', $sql->todo->take(4));
        });
gralias
  • 31
  • 4