whereHas vs join -> applying global scopes - php

I have a query with a few joins and global scopes for each model, for example:
SELECT *
FROM products p
WHERE EXISTS(
SELECT *
FROM orders o
WHERE o.user_id = 4
AND o.status_id = 1
AND o.user_id = 3
AND EXISTS(
SELECT *
FROM suborders s
WHERE s.status_id = 2
)
);
This means that I can simply write a few whereHas statements and my query will have some nested EXIST clauses, but all global scopes (like the user_id on the orders table) will be applied automatically:
$this->builder->whereHas('orders', function ($q) {
$q->where('status_id', '=', 1)
->whereHas('suborder', function ($q) {
$q->where('status_id', '=', 2);
});
});
The problem is that it's slow, it would be much better to have something with plain JOINs instead of ugly nested EXIST clauses:
SELECT *
FROM products p
INNER JOIN orders o ON p.order_id = o.id
INNER JOIN suborders s ON o.id = s.order_id
WHERE o.status_id = 1
AND u.user_id = 3
AND s.status_id = 2;
The problem with this is that I need to use query builder to join these:
$this->builder->join('orders', 'products.order_id', '=', 'orders.id')
->join('suborders', 'orders.id', '=', 'suborders.order_id')
->where('orders.status_id', 1)
->where('suborders.id', 2);
And that will not include any of my global scopes on Order and Suborder model. I need to do it manually:
$this->builder->join('orders', 'products.order_id', '=', 'orders.id')
->join('suborders', 'orders.id', '=', 'suborders.order_id')
->where('orders.status_id', 1)
->where('suborders.id', 2)
->where('orders.user_id', 3);
It's bad, because I need to replicate my global scopes logic every time I write a query like this, while whereHas applies them automatically.
Is there a way to join a table, and have all global scopes from the joined model applied automatically?

I have worked with a similar issue before and come up with some approach.
First, lets define a macro for Illuminate\Database\Eloquent\Builder in a service provider:
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
class AppServiceProvider
{
public function boot()
{
Builder::macro('hasJoinedWith', function ($table) {
return collect(
$this->getQuery()->joins
)
->contains(function (JoinClause $joinClause) use ($table) {
return $joinClause->table === $table;
})
});
}
}
Then, lets define the scopes:
class Product extends Model
{
public function scopeOrderStatus($query, $orderStatusId)
{
if (! $query->hasJoinedWith('orders')) {
$query->join('orders', 'products.order_id', '=', 'orders.id');
}
return $query->where('orders.status_id', $orderStatusId)
}
public function scopeOrderUser($query, $userId)
{
if (! $query->hasJoinedWith('orders')) {
$query->join('orders', 'products.order_id', '=', 'orders.id');
}
return $query->where('orders.user_id', $userId)
}
public function scopeSubOrder($query, $subOrderId)
{
if (! $query->hasJoinedWith('orders')) {
$query->join('orders', 'products.order_id', '=', 'orders.id');
}
if (! $query->hasJoinedWith('suborders')) {
$query->join('suborders', 'orders.id', '=', 'suborders.order_id');
}
return $query->where('suborders.id', $subOrderId)
}
}
Finally, you use the scopes together:
Product::orderStatus(1)
->subOrder(2)
->orderUser(3)
// This is optional. There are possibly duplicate products.
->distinct()
->get();
This is the best approach that I can come up with, there may be better ones.

Actually, that what you describe can't be true (will provide proof below). EXISTS don't make query slower and in 99% of case it make faster!
So laravel don't provide ability to make joins for relations from box.
I saw different solutions for this and package on github, but I will not provide link to it, because when I reviewed logic, found a lot of problems with field selecting and rare cases.
Laravel don't generate code with EXISTS as you describe, it add relation search by ID to each EXISTS subquery like
SELECT *
FROM products
WHERE EXISTS(
SELECT *
FROM orders
WHERE o.user_id = 4
AND o.status_id = 1
AND o.id = p.order_id
AND EXISTS(
SELECT *
FROM suborders s
WHERE s.status_id = 2
AND s.id = o.suborder_id
)
);
attention to AND o.id = p.order_id and AND s.id = o.suborder_id
When you do select with joins, you should exactly set SELECT from main table, to have right filled Model fields.
Global scopes are global. Purpose to be really global. If you have more then 1-2 places without them, then you should find another solution instead of global scopes. Otherwise you application will be very hard to support and write new code. Developer should not remember every time, that there can be global scopes that he should turn off

Related

withExists() or withCount() for nested relationship (Many To Many and One To Many) in Laravel Eloquent

I have the following relationships in models:
Product.php
public function skus()
{
return $this->belongsToMany(Sku::class);
}
Sku.php
public function prices()
{
return $this->hasMany(Price::class);
}
I need to get an attribute indicating whether a product has at least one price or not (in the extreme case, just the number of prices).
Product::withExists('sku.prices') or Product::withCount('sku.prices')
I know about this repository https://github.com/staudenmeir/belongs-to-through, but I prefer to use complex query once
UPDATE: I have already written a sql query for this purpose, but I don't know how to do it in Laravel:
SELECT
*,
EXISTS (SELECT
*
FROM prices
INNER JOIN skus
ON prices.sku_id = skus.id
INNER JOIN product_sku
ON skus.id = product_sku.sku_id
WHERE products.id = product_sku.product_id
) AS prices_exists
FROM products
Here you can get at least one record
$skuPrice = Sku::with('prices')
->has('prices', '>=', 1)
->withCount('prices')
->get();

Laravel eloquent from DB

I have a very complicated db query, but I would like to know or is a possibility to short it and make it easier by eloquent?
My Model and his db is :
class Order extends Model
{
public $timestamps = false;
public $incrementing = false;
public function products()
{
return $this->hasMany(OrderProducts::class);
}
public function statuses()
{
return $this->belongsToMany(OrderStatusNames::class, 'order_statuses', 'order_id', 'status_id');
}
public function actualKioskOrders()
{
return
$rows = DB::select("SELECT o.id, o.number, o.name client_name, o.phone,
o.email, o.created_at order_date, osn.name actual_status
FROM orders o
JOIN order_statuses os ON os.order_id = o.id
JOIN (SELECT o.id id, MAX(os.created_at) last_status_date FROM orders o
JOIN order_statuses os ON os.order_id = o.id GROUP BY o.id) t
ON t.id = os.order_id AND t.last_status_date = os.created_at
JOIN order_status_names osn ON osn.id = os.status_id
WHERE os.status_id != 3");
}
}
Of course you can. Laravel query builder implements everything you need.
See Laravel Docs: Query Builder, it have join methods, where clause methods and select methods.
You can do for example the following:
Order::select(['id','number', 'name', 'client_name'])
->where('status_id', '!=', 3)
->join('order_statuses', 'order_statuses.order_id, '=', 'orders.id')
->get()
That's just an example on how you can create queries. Chain many methods that you need to create your query, the docs show many ways to do it, including with more complex joins if you need.

Laravel many to many loading related models with count

I am trying to link 4 tables and also add a custom field calculated by counting the ids of some related tables using laravel.
I have this in SQL which does what I want, but I think it can be made more efficient:
DB::select('SELECT
posts.*,
users.id AS users_id, users.email,users.username,
GROUP_CONCAT(tags.tag ORDER BY posts_tags.id) AS tags,
COUNT(DISTINCT comments.id) AS NumComments,
COUNT(DISTINCT vote.id) AS NumVotes
FROM
posts
LEFT JOIN comments ON comments.posts_id = posts.id
LEFT JOIN users ON users.id = posts.author_id
LEFT JOIN vote ON vote.posts_id = posts.id
LEFT JOIN posts_tags ON posts_tags.posts_id = posts.id
LEFT JOIN tags ON tags.id = posts_tags.tags_id
GROUP BY
posts.id,
posts.post_title');
I tried to implement it using eloquent by doing this:
$trending=Posts::with(array('comments' => function($query)
{
$query->select(DB::raw('COUNT(DISTINCT comments.id) AS NumComments'));
},'user','vote','tags'))->get();
However the NumComments value is not showing up in the query results.
Any clue how else to go about it?
You can't do that using with, because it executes separate query.
What you need is simple join. Just translate the query you have to something like:
Posts::join('comments as c', 'posts.id', '=', 'c.id')
->selectRaw('posts.*, count(distinct c.id) as numComments')
->groupBy('posts.id', 'posts.post_title')
->with('user', 'vote', 'tags')
->get();
then each post in the collection will have count attribute:
$post->numComments;
However you can make it easier with relations like below:
Though first solution is better in terms of performance (might not be noticeable unless you have big data)
// helper relation
public function commentsCount()
{
return $this->hasOne('Comment')->selectRaw('posts_id, count(*) as aggregate')->groupBy('posts_id');
}
// accessor for convenience
public function getCommentsCountAttribute()
{
// if relation not loaded already, let's load it now
if ( ! array_key_exists('commentsCount', $this->relations)) $this->load('commentsCount');
return $this->getRelation('commentsCount')->aggregate;
}
This will allow you to do this:
$posts = Posts::with('commentsCount', 'tags', ....)->get();
// then each post:
$post->commentsCount;
And for many to many relations:
public function tagsCount()
{
return $this->belongsToMany('Tag')->selectRaw('count(tags.id) as aggregate')->groupBy('pivot_posts_id');
}
public function getTagsCountAttribute()
{
if ( ! array_key_exists('tagsCount', $this->relations)) $this->load('tagsCount');
$related = $this->getRelation('tagsCount')->first();
return ($related) ? $related->aggregate : 0;
}
More examples like this can be found here http://softonsofa.com/tweaking-eloquent-relations-how-to-get-hasmany-relation-count-efficiently/
as of laravel 5.3 you can do this
withCount('comments','tags');
and call it like this
$post->comments_count;
laravel 5.3 added withCount

Laravel 4 select column from another table in subquery

I am attempting to do the equivalent of this:
select p.id, p.title, b.brand,
(select big from images where images.product_id = p.id order by id asc limit 1) as image
from products p
inner join brands b on b.id = p.brand_id
Here is where I am at now, but it of course doesn't work:
public function getProducts($brand)
{
// the fields we want back
$fields = array('p.id', 'p.title', 'p.msrp', 'b.brand', 'p.image');
// if logged in add more fields
if(Auth::check())
{
array_push($fields, 'p.price_dealer');
}
$products = DB::table('products as p')
->join('brands as b', 'b.id', '=', 'p.brand_id')
->select(DB::raw('(select big from images i order by id asc limit 1) AS image'), 'i.id', '=', 'p.id')
->where('b.active', '=', 1)
->where('p.display', '=', 1)
->where('b.brand', '=', $brand)
->select($fields)
->get();
return Response::json(array('products' => $products));
}
I don't really see anything in the docs on how to do this, and I can't seem to piece it together from other posts.
In "regular" SQL, the subquery is treated AS a column, but I am not sure how to string that together here. Thanks for any help on this.
I strongly recommend you to use Eloquent, instead of pure SQL. It's one of the most beautful things in Laravel. Two models and relations and it's done! If you need to use pure SQL like that, put it all in DB::raw. It's easier, simpler and (ironically) less messy!
With the models, you could use relations between the two tables (represented by the models itself) and say (so far I understood) that Brands belongs to Products, and Images belongs to Product. Take a look at Eloquent's documentation on Laravel. Probably will be more clearly.
Once the relations are done, you can only say that you wanna get
$product = Product::where(function ($query) use ($brand){
$brand_id = Brand::where('brand', '=', $brand)->first()->id;
$query->where('brand_id', '=', $brand_id);
})
->image()
->get();
That and a better look at Eloquent's documentation should help you to do the job.
P.S.: I didn't test the code before send it and wrote it by head, but i think it works.

How to convert this to a nice Eloquent query

How to query with Eloquent, all users without a certain type of certificate?
Laravel 4
I've got 2 tables:
users table:
->id
->name
certificats table:
->id
->user_id
->certificate_type
I`m struggling with this for hours now. Last thing i tried was:
$users = User::with(array('certificate' => function($query)
{
$query->where('type','!=','SL');
}))->get();
This gives me all the users, but i was trying to get all the users without certificate type 'SL'.
-- edit:
Spencer7593's raw query below works. But i`m not getting the eloquent query to work.
SELECT u.*
FROM users u
LEFT
JOIN certificates c
ON c.user_id = u.id
AND c.type = 'SL'
WHERE c.user_id IS NULL
The relationship:
public function certificate(){
return $this->hasMany('Certificate');
}
public function certificate(){
return $this->belongsTo('User');
}
The SQL to get the result set you want is fairly simple.
SELECT u.*
FROM users u
LEFT
JOIN certificates c
ON c.user_id = u.id
AND c.type = 'SL'
WHERE c.user_id IS NULL
That's a familiar pattern called an "anti-join". Basically, it's a LEFT JOIN look for matching rows, along with rows from users that don't have a match, and then filter out all rows that did get a match, and we're left with rows from users that don't have match.
The trick is going to be getting Eloquent to generate that SQL for you. To get Eloquent to do that, you need to tell eloquent to do a LEFT JOIN, and add a WHERE clause,
maybe something like this would be close:
->left_join('certificate AS c', function($join){
$join->on('c.user_id','=','user.id');
$join->and_on('c.type','=','SL');
})
->where_null('c.user_id')
FOLLOWUP
(For the benefit of those who might not read the comments)
Klass Terst (OP), reports syntax problems in the attempt at Eloquent (in the answer above): left_join needed to be replaced with leftJoin, and the and_on wasn't recognized. (The latter may have been my invention, based on the convention used in with where, and_where, or_where.)
$users = DB::table('users')
->select('users.id','users.name')
->leftJoin('certificate AS c', function($join){
$join->on('c.user_id','=','user.id');
$join->on('c.type','=','SL');
})
->where_null('c.user_id');
I believe the problem is in your relationships.
Models
class User extends Eloquent
{
public function certificates()
{
return $this->hasMany('Certificate');
}
}
class Certificate extends Eloquent
{
public function user()
{
return $this->belongsTo('User');
}
}
Controller
$users = User::with(array('certificates' => function($query)
{
$query->where('type','!=','SL');
}))->get();
return View::make('yourView')->with('users',$users);
View
#foreach($users as $user)
{{ $user->name }} // User's name
#foreach($user->certificates as $certificate)
{{ $certificate->certificate_type }} // Certificate type shouldn't be 'SL'
#endforeach
#endforeach
I hope this helps!

Categories