I have 3 models.
User.rb
has_many :user_cards
UserCard.rb
belongs_to :CardGroup
I want to get all users, with cards and the CardGroup information in one single query.
User.joins("LEFT OUTER JOIN user_cards ON user_cards.user_id = users.id AND user_cards.created_at BETWEEN '#{#start_time.to_s(:db)}' AND '#{#end_time.to_s(:db)}'")
.group('users.id, users.mobile, user_cards.user_id, user_cards.bin)
.select(
'users.id AS uid, users.name AS uname, users.mobile AS umobile,' \
'count(distinct(user_cards.id)) AS total_cards, \
'user_cards.bin AS card_bins'
)
Now, I want to join the third model CardGroup as well and get user_card.card_group.name in the above query.
How can I do it with nested join / any other way?
First, you should use snake case in ruby.
belongs_to :card_group
Second, this part of your join statement would be better off in a where clause.
AND user_cards.created_at BETWEEN '#{#start_time.to_s(:db)}' AND '#{#end_time.to_s(:db)}'"
Third, the join query itself is more readable in Ruby. That's why you put the relation on the model in the first place. So replace:
LEFT OUTER JOIN user_cards ON user_cards.user_id = users.id
with
User.left_outer_joins(:user_cards)
Now adding a 3rd model joined through the 2nd is as easy as:
User.left_outer_joins(user_cards: :card_group)
Related
I have been trying to find a solution to my problem for a few days, so I am turning towards the community, hopefully I am not missing something obvious here.
I have 2 models in rails:
class Room
has_many :accesses
end
class Access
belongs_to :accessor, polymorphic: true
end
Accessor can be of 2 types: Person or Team
I am trying to find the most efficient way to find the rooms that a user has access to, but which are not accessible from any teams.
I tried:
Room.joins(:accesses).where(accesses: {accessor: Person.find(1234)}).where.not(accesses: {accessor_type: Team'})
But that returns the rooms that people have accesses to, it does not filter out the ones that Team AND People have access to.
I am thinking the having clause is the way to go, in which it would count the number of Teams accesses to rooms, and keep the rooms that have 0 team accesses. Though all my attempts are failing.
I would love to hear any advice.
Left join
Instead of using HAVING, which requires us to add a GROUP BY, I'd start with a LEFT JOIN and a WHERE.
You can do this by left-joining to the room_accesses table specifically on "Team" accessor_type. We're left-joining because we're going to scope this join to only team accesses, and select only the rows where no such accesses exist. An inner join would not return these rows at all. We'll need to use a table alias as we're already using the room_accesses table to join to the person you are looking up.
We may as well admit Rails isn't great at this level of query abstraction, so let's just construct the raw SQL fragments for our first solution:
person = Person.find(1234)
person.rooms.joins(
"LEFT JOIN room_accesses team_accesses
ON team_accesses.room_id = rooms.id
AND team_accesses.accessor_type = 'Team'"
).where("team_accesses.id IS NULL")
This generates, for SQLite,
SELECT "rooms".* FROM "rooms"
INNER JOIN "room_accesses"
ON "rooms"."id" = "room_accesses"."room_id"
LEFT JOIN room_accesses team_accesses
ON team_accesses.room_id = rooms.id
AND team_accesses.accessor_type = 'Team'
WHERE "room_accesses"."accessor_id" = 1
AND "room_accesses"."accessor_type" = 'Person'
AND (team_accesses.id IS NULL)
Having
You can do this with aHAVING by similarly joining to room_accesses again with the team_accesses alias, grouping by rooms.id (since we want at most one record per room), and selecting the groups HAVING a zero count of team accesses:
person.rooms.joins(
"LEFT JOIN room_accesses team_accesses
ON team_accesses.room_id = rooms.id
AND team_accesses.accessor_type = 'Team'"
).group("rooms.id").having("COUNT(team_accesses.id) = 0")
generates:
SELECT "rooms".* FROM "rooms"
INNER JOIN "room_accesses"
ON "rooms"."id" = "room_accesses"."room_id"
LEFT JOIN room_accesses team_accesses
ON team_accesses.room_id = rooms.id
AND team_accesses.accessor_type = 'Team'
WHERE "room_accesses"."accessor_id" = 1
AND "room_accesses"."accessor_type" = 'Person'
GROUP BY rooms.id
HAVING (COUNT(team_accesses.id) = 0)
Using associations instead of raw SQL
You can get halfway there in Rails by defining a scoped association:
class Room < ApplicationRecord
has_many :room_accesses
has_many :team_accesses, ->{ where accessor_type: "Team" }, class_name: "RoomAccess"
end
Assuming you're using a recent version of ActiveRecord, this allows you to do
person.rooms.left_joins(:team_accesses)
However, the table name used for this left joins is "team_accesses_rooms", which is predictable in this simple case but not part of the public API to my knowledge and subject to being changed if other joins are used in this same query. Still, if you're feeling daring:
person.rooms.left_joins(:team_accesses).where(team_accesses_rooms: {id: nil})
Frankly I would not recommend this method as you're relying on a table alias that you're not in control of and is not obvious where it comes from. With the raw SQL, you are in control of it and it's obvious where it came from.
I was trying to use this functionality introduced in #645 with conditional 2nd degree has_many ... through relationships with little success.
In my case:
a Course has_many :user_assigned_content_skills, -> { where(source: 'user') }, class_name: "ContentSkill"
and a ContentSkill belongs_to :skill and belongs_to :course
Then Course.ransack({user_assigned_content_skills_skill_name_not_cont: 'ruby'}).result.to_sql returns the following:
"SELECT courses.* FROM courses LEFT OUTER JOIN content_skills ON content_skills.course_id = courses.id AND content_skills.source = 'user' LEFT OUTER JOIN skills ON skills.id = content_skills.skill_id WHERE (skills.name NOT ILIKE '%ruby%')"
This means false positives again if a course has multiple content_skills. Any ideas how to retrieve all courses not being associated with a given skill name?
Many thanks for any insights!
You can get ids of courses associated with a given skill name, and then get a list of courses with ids that don't match the previous found. You can even make it as one composite SQL query.
Course.where.not(id: Course.ransack({user_assigned_content_skills_skill_name_cont: 'ruby'}).result)
This will generate an SQL like this:
SELECT courses.*
FROM courses
WHERE courses.id NOT IN (
SELECT courses.id FROM courses
LEFT OUTER JOIN content_skills ON content_skills.course_id = courses.id AND content_skills.source = 'user'
LEFT OUTER JOIN skills ON skills.id = content_skills.skill_id
WHERE (skills.name ILIKE '%ruby%')
)
Is it possible to do a left outer join in Rails4 with group by and counts. I am trying to write a scope which will do a left outer join of users with the messages, comments and likes tables and then group by id to get total count. In case there is no association, the count should be zero.
So the final result set would be cuuser.*, message_count, likes_count and comments_count. Any idea how this can be accomplished? Thanks in Advance!
class Cuuser < ActiveRecord::Base
has_and_belongs_to_many :groups
has_many :messages
has_many :comments
has_many :likes
validates :username, format: { without: /\s/ }
scope :superusers, -> { joins(:comments, :likes).
select('cuusers.id').
group('cuusers.id').
having('count(comments.id) + count(likes.id) > 2')}
end
You could drop to SQL strings:
joins("LEFT OUTER JOIN comments ON comments.cuuser_id = cuuser.id LEFT OUTER JOIN likes ON likes.cuuser_id = cuuser.id LEFT OUTER JOIN messages on messages.cuuser_id = cuuser.id")
This isn't great. You're sacrificing some portability and the ability of ActiveRecord to guess the association.
If you used the Squeel gem you could use the following format:
joins{ [comments.outer, likes.outer, messages.outer] }
Squeel exposes Arel in a slightly more sane format, so you can do things like left outer joins while still guessing associations from the model definitions.
You could use Arel of course, but things get very clunky very quickly.
To get your counts, try:
select('cuusers.id, count(messages.id) as message_count, count(likes.id) as likes_count, count(comments.id) as comments_count').
They should be available as attributes on the returned objects, just like ordinary database fields.
How do I get all the users who do not have a car?
class User < ActiveRecord::Base
has_one :car
end
class Car < ActiveRecord::Base
belongs_to :user
end
I was doing the following:
all.select {|user| not user.car }
That worked perfect until my database of users and cars got too big and now I get strange errors, especially when I try and sort the result. I need to do the filtering in the query and the ordering as well as part of the query.
UPDATE: What I did was the following:
where('id not in (?)', Car.pluck(:user_id)).order('first_name, last_name, middle_name')
It's fairly slow as Rails has to grab all the user_ids from the cars table and then issue a giant query. I know I can do a sub-query in SQL, but there must be a better Rails/ActiveRecord way.
UPDATE 2: I now have a noticeably more efficient query:
includes(:car).where(cars: {id: nil})
The answer I accepted below has joins with a SQL string instead of includes. I don't know if includes is more inefficient because it stores the nil data in Ruby objects whereas joins might not? I like not using strings...
One way is to use a left join from the users table to the cars table and only take user entries that don't have any corresponding values in the cars table, this looks like:
User.select('users.*').joins('LEFT JOIN cars ON users.id = cars.user_id').where('cars.id IS NULL')
Most of the work that needs to be done here is SQL. Try this:
User.joins("LEFT OUTER JOIN cars ON users.id = cars.user_id").where("cars.id IS NULL")
It is incredibly inefficient to do this with ruby, as you appear to be trying to do.
You can throw an order on there too:
User.
joins("LEFT OUTER JOIN cars ON users.id = cars.user_id").
where("cars.id IS NULL").
order(:first_name, :last_name, :middle_name)
You can make this a scope on your User model so you only have one place to deal with it:
class User < ActiveRecord::Base
has_one :car
def self.without_cars
joins("LEFT OUTER JOIN cars ON users.id = cars.user_id").
where("cars.id IS NULL").
order(:first_name, :last_name, :middle_name)
end
end
This way you can do:
User.without_cars
In your controller or another method, or even chain the scope:
User.without_cars.where("users.birthday > ?", 18.years.ago)
to find users without cars that are under 18 years old (arbitrary example, but you get the idea). My point is, this kind of thing should always be made into a scope, so it can be chained with other scopes :) Arel is awesome that way.
The problem is that when a Restaurant does not have any MenuItems that match the condition, ActiveRecord says it can't find the Restaurant. Here's the relevant code:
class Restaurant < ActiveRecord::Base
has_many :menu_items, dependent: :destroy
has_many :meals, through: :menu_items
def self.with_meals_of_the_week
includes({menu_items: :meal}).where(:'menu_items.date' => Time.now.beginning_of_week..Time.now.end_of_week)
end
end
And the sql code generated:
Restaurant Load (0.0ms)←[0m ←[1mSELECT DISTINCT "restaurants".id FROM "restaurants"
LEFT OUTER JOIN "menu_items" ON "menu_items"."restaurant_id" = "restaurants"."id"
LEFT OUTER JOIN "meals" ON "meals"."id" = "menu_items"."meal_id" WHERE
"restaurants"."id" = ? AND ("menu_items"."date" BETWEEN '2012-10-14 23:00:00.000000'
AND '2012-10-21 22:59:59.999999') LIMIT 1←[0m [["id", "1"]]
However, according to this part of the Rails Guides, this shouldn't be happening:
Post.includes(:comments).where("comments.visible", true)
If, in the case of this includes query, there were no comments for any posts, all the posts would still be loaded.
The SQL generated is a correct translation of your query. But look at it,
just at the SQL level (i shortened it a bit):
SELECT *
FROM
"restaurants"
LEFT OUTER JOIN
"menu_items" ON "menu_items"."restaurant_id" = "restaurants"."id"
LEFT OUTER JOIN
"meals" ON "meals"."id" = "menu_items"."meal_id"
WHERE
"restaurants"."id" = ?
AND
("menu_items"."date" BETWEEN '2012-10-14' AND '2012-10-21')
the left outer joins do the work you expect them to do: restaurants
are combined with menu_items and meals; if there is no menu_item to
go with a restaurant, the restaurant is still kept in the result, with
all the missing pieces (menu_items.id, menu_items.date, ...) filled in with NULL
now look aht the second part of the where: the BETWEEN operator demands,
that menu_items.date is not null! and this
is where you filter out all the restaurants without meals.
so we need to change the query in a way that makes having null-dates ok.
going back to ruby, you can write:
def self.with_meals_of_the_week
includes({menu_items: :meal})
.where('menu_items.date is NULL or menu_items.date between ? and ?',
Time.now.beginning_of_week,
Time.now.end_of_week
)
end
The resulting SQL is now
.... WHERE (menu_items.date is NULL or menu_items.date between '2012-10-21' and '2012-10-28')
and the restaurants without meals stay in.
As it is said in Rails Guide, all Posts in your query will be returned only if you will not use "where" clause with "includes", cause using "where" clause generates OUTER JOIN request to DB with WHERE by right outer table so DB will return nothing.
Such implementation is very helpful when you need some objects (all, or some of them - using where by base model) and if there are related models just get all of them, but if not - ok just get list of base models.
On other hand if you trying to use conditions on including tables then in most cases you want to select objects only with this conditions it means you want to select Restaurants only which has meals_items.
So in your case, if you still want to use only 2 queries (and not N+1) I would probably do something like this:
class Restaurant < ActiveRecord::Base
has_many :menu_items, dependent: :destroy
has_many :meals, through: :menu_items
cattr_accessor :meals_of_the_week
def self.with_meals_of_the_week
restaurants = Restaurant.all
meals_of_the_week = {}
MenuItems.includes(:meal).where(date: Time.now.beginning_of_week..Time.now.end_of_week, restaurant_id => restaurants).each do |menu_item|
meals_of_the_week[menu_item.restaurant_id] = menu_item
end
restaurants.each { |r| r.meals_of_the_week = meals_of_the_week[r.id] }
restaurants
end
end
Update: Rails 4 will raise Deprecation warning when you simply try to do conditions on models
Sorry for possible typo.
I think there is some misunderstanding of this
If there was no where condition, this would generate the normal set of two queries.
If, in the case of this includes query, there were no comments for any
posts, all the posts would still be loaded. By using joins (an INNER
JOIN), the join conditions must match, otherwise no records will be
returned.
[from guides]
I think this statements doesn't refer to the example Post.includes(:comments).where("comments.visible", true)
but refer to one without where statement Post.includes(:comments)
So all work right! This is the way LEFT OUTER JOIN work.
So... you wrote: "If, in the case of this includes query, there were no comments for any posts, all the posts would still be loaded." Ok! But this is true ONLY when there is NO where clause! You missed the context of the phrase.