Optimize a Deeply Nested Eloquent Loop - laravel

How would I take something with a crazy N+1 issue like:
foreach ($serie->categories as $category) {
foreach ($category->processes as $process) {
foreach ($process->cards as $card) {
foreach ($card->groups as $group) {
$group->upload;
}
}
}
}
And make it into one or two statements?
I'm using Laravel's Eloquent ORM.

You can do deeply nested eager loading with:
Serie::with('categories.processes.cards.groups')->get();
This already loads all processes per category, cards per process, etc.
Look at the documentation
$books = Book::with('author.contacts')->get();
If you want a quick way to get all groups, try:
$series = Serie::with('categories.processes.cards.groups')->get()->toArray();
$groups = array_pluck($series, 'categories.processes.cards.groups');
This returns the attributes for every group in array format. You only need to find a new way to perform the ->upload() method on the group object.

Related

Laravel Eloquent model architecture and pivot table with composite keys

I have a Laravel specific problem, maybe caused by creating database model without previous Eloquent knowledge, and now I am really struggling with correctly retrieving required data.
Situation:
There are 4 entity models: Connection, Order, Seat, AdditionalItem.
Connection has X seats. You can create an Order, for Y connections, and for X seats for every connection, if available. And for every connection seat you can have N Additional items.
So for example, I can create and order for one connection with 2 seats, and another connection with 3 seats. And for some seats I can order additional items.
What I did is this:
I created a pivot table called "order_connection_seats", with composite keys "order_id", "connection_id", "seat_id" and some other attributes. It made total sense to me, because without need to create autoincrement primary keys I can easily store what i need. What looks to me little dirty is the solution for additional items. I have another pivot table "order_connection_seat_additional_items" with keys "order_id", "connection_id", "seat_id" and "additional_item_id".
The problem is, that when I try to correctly select data to be able to iterate over it, the query gets quite complicated and even doesn't work correctly.
What I need to do is something like (without lazy loading):
$orders = Order::query()->with(['connections' => ...])->get();
foreach ($orders as $order) {
foreach ($order->connections as $connection) {
foreach ($connection->seats as $seat) {
foreach ($seat->additionalItems as $additionalItem) {
}
}
}
}
OR
$connections = Connection::query()->with(['orders' => ...])->get();
foreach ($connections as $connection) {
foreach ($connection->orders as $order) {
foreach ($order->connection->seats as $seat) {
foreach ($seat->additionalItems as $additionalItem) {
}
}
}
}
Is this achievable with Eloquent, or is the data model incorrect and should be made differently to ge the desired result?
Thank you in advance.

Laravel / Eloquent: Is it possible to select all child model data without setting a parent?

I have various parent/child relationships, drilling down a few levels. What I want to know is if its possible to do something like this:
$student = Student::find(1);
$student->bursaries()->enrolments()->courses()->where('course','LIKE','%B%');
(With the end goal of selecting the course which is like '%B%'), or if I would have to instead use the DB Query builder with joins?
Models / Relationships
Student:
public function bursaries() {
return $this->hasMany('App\StudentBursary');
}
StudentBursary:
public function enrolments() {
return $this->hasMany('App\StudentBursaryEnrolment');
}
If what you want is to query all courses, from all enrollments, from all bursaries, from a students, then, unfortunately, you are one table too many from getting by with the Has Many Through relationship, because it supports only 3 tables.
Online, you'll find packages that you can import / or answers that you can follow to provide you more though of solutions, for example:
1) How to use Laravel's hasManyThrough across 4 tables
2) https://github.com/staudenmeir/eloquent-has-many-deep
Anyhow, bellow's something you can do to achieve that with Laravel alone:
// Eager loads bursaries, enrolments and courses, but, condition only courses.
$student = Student::with(['bursaries.enrolments.courses' => function($query) {
$query->where('course','LIKE','%B%');
}])->find(1);
$enrolments = collect();
foreach($student->bursaries as $bursary) {
$enrolments = $enrolments->merge($bursary->enrolments);
}
$courses = collect();
foreach ($enrolments as $enrolment) {
$courses = $courses->merge($enrolment->courses);
}
When you do $student->bursaries() instead of $student->bursaries, it returns a query builder instead of relationship map. So to go to enrolments() from bursaries() you need to do a bursaries()->get(). It should look like this.
$student->bursaries()->get()[0]->enrolments(), added the [0] because im using get(), you can use first() to avoid the [0]
$student->bursaries()->first()->enrolments()
But I'm not sure if it will suffice your requirement or not.

Order by count in many to many polymorphic relation in Laravel

Let's take the example from the doc : https://laravel.com/docs/5.7/eloquent-relationships#many-to-many-polymorphic-relations it's easy to get all posts with their tags count doing Post::withCount('tags')->get().
But how to get all tags with their usage count ? To have them ordered by most used / less used.
If I do Tag::withCount(['video', 'post'])->get() I will have 2 attributes videos_count and posts_count. In my case I would like a unique taggables_count that will be the sum of the two. In a perfect world by adding a subselect querying the pivot table.
I would suggest simply doing the call you already did, which is Tag::withCount(['video', 'post'])->get(), and add this to your Tag model:
// Tag.php
class Tag
{
...
// Create an attribute that can be called using 'taggables_count'
public function getTaggablesCountAttribute()
{
return $this->videos_count + $this->posts_count;
}
...
}
and then in your loop (or however you use the items in the collection):
#foreach($tags as $tag)
{{ $tag->taggables_count }}
#endforeach
This setup requires you to get the Tags with the withCount['video', 'post'] though. If you do not, you will likely get 0in return for $tag->taggables_count.
If you're really concerned about speed, you would have to create the query manually and do the addition in there.
So after more searching I find out there is no way to do it with only in one query due to the fact that in mysql we can't do a select on subselet results. So doing Tag::withCount(['videos', 'posts']) and trying to sum in the query the videos_count and posts_count will not work. My best approach was to create a scope that read results in the pivot table :
public function scopeWithTaggablesCount($query) {
if (is_null($query->getQuery()->columns)) {
$query->select($query->getQuery()->from . '.*');
}
$query->selectSub(function ($query) {
$query->selectRaw('count(*)')
->from('taggables')
->whereColumn('taggables.tag_id', 'tags.id');
}, 'taggables_count');
return $query;
}
To use it :
$tags = Tag::withTaggablesCount()->orderBy('name', 'ASC')->get();
So now we have a taggables_count for each tag and it can be used to order by. Hope it can help others.

How to re-order an Eloquent collection?

I've got a collection of records retrieved via a relationship, and I'd like to order them by the created_at field. Is this possible in Eloquent?
Here is how I am retrieving the collection:
$skills = $employee->skills;
I'd like to order this $skills collection by their creation. I've tried $skills->orderBy('created_at', 'desc'); but the Collection class does not have an orderBy method.
I guess this problem is very simple and I'm missing something..
You can do this in two ways. Either you can orderBy your results while query, as in
$employee->skills()->orderBy('created_at', 'desc')->get();
OR
You can use sortBy and sortByDesc on your collection
The reason this is failing is that orderBy is a query method not a collection method.
If you used $skills = $employee->skills()->orderBy('created_at', 'desc')->get();, this would query the skills in the order you want.
Alternatively, if you already had a collection that you wanted to re-order, you could use the sortBy or sortByDesc methods.
You need to add the orderBy constraint on the query instead of the relationship.
For e.g,
$employees = Employee::where('salary', '>', '50000') // just an example
->with('skills') // eager loading the relationship
->orderBy('created_at', 'desc')
->get();
and then:
foreach($employees as $employee)
{
var_dump($employee->skill);
}
If you want the results to always be ordered by a field, you can specify that on the relationship:
Employee.php
public function skills() {
return $this->hasMany(Skills::class)->orderBy('created_at');
}
If you just want to order them sometimes, you can use orderBy(), but on the relationship, not the property:
$skills = $employee->skills()->orderBy('created_at')->get();
Collection has sortBy and sortByDesc
$skills = $skills->sortBy('created_at');
$skills = $skills->sortByDesc('created_at');
This Stackoverflow question askes how to order an Eloquent collection. However, I would like to propose a different solution to use instead given the example in the question. I would like to recommend to use an ordering on the query itself for performance reasons.
Like #Don't Panic proposes you can specify a default ordering on the relationship for great reusability convenience:
app/Models/Employee.php
public function skills() {
return $this->hasMany(Skills::class)->orderBy('created_at');
}
However, if you have already set an ordering on your query like we do in the code above, any additional orderings will be ignored. So that is a bummer if you want to use a different sorting in another situation. To overwrite this default ordering and re-order the query with a new ordering, one needs to use the reorder() method. For example:
// Get a Collections of Skill-models ordered by the oldest skill first.
$skills = $employee->skills()->reorder()->orderByDesc('created_at')->get();
// Same result as the previous example, but different syntax.
$skills = $employee->skills()->reorder()->oldest()->get();
// Or just give some arguments to the reorder() method directly:
$skills = $employee->skills()->reorder('created_at', 'desc')->get();

How can I minimize the amount of queries fired?

I'm trying to create a tag cloud in Laravel 4.1, tags can belong to many posts, and posts can have many tags. I'm using a pivot table to achieve this (post_tag).
So far I've come up with this to fetch the tags and check how many times it's used:
public static function tagCloud($tags, $max = 10) {
foreach($tags->toArray() as $tag) {
$count = DB::table('post_tag')
->where('tag_id', $tag['id'])
->count();
$cloud[$tag['slug']] = $count;
}
sd($cloud);
}
I pass Tag::all() to the above function. Obviously that's going to fire a crazy amount of queries on the database, which is what I'm trying to avoid. Normally you'd use eager loading to fix this problem, but it seems the documentation does not mention anything about eager loading pivot tables in combination with aggregates.
I hope someone can shine a light on how to optimize this current function or can point me in the right direction of a better alternative.
Sometimes it's just hard reduce them, but Laravel Cache is your friend:
$users = DB::table('users')->remember(10)->get();
It will remember your query for 10 minutes.
I believe you have a many-to-many relationship with posts in your tags table like this:
public function posts()
{
return $this->belongsToMany('Post');
}
So, you are able to do something like this:
$tags = Tag::with('posts')->get();
Then you may loop though the tags to find out how many posts each tag contains, like this:
foreach($tags as $tag) {
$tag->posts->count();
};
So, you may write your function like this:
public function scopeTagCloude($query) {
$cloud = [];
$query->with('posts')->get()->each(function($tag) use (&$cloud) {
$cloud[$tag['slug']] = $tag->posts->count();
});
return $cloud;
}
You may call this function like this:
$tagCloude = Tag::tagCloude();
If you dd($tagCloude) then you'll get something like this (example is taken from my blog app):
array (size=4)
'general' => int 4
'special' => int 5
'ordinary' => int 5
'extra_ordinary' => int 2

Resources