ActiveRecord query on many-to-many self join table - sql

I have a many-to-many self join table called people that uses the following model:
class Person < ApplicationRecord
has_and_belongs_to_many :children,
class_name: "Person",
join_table: "children_parents",
foreign_key: "parent_id",
association_foreign_key: "child_id",
optional: true
has_and_belongs_to_many :parents,
class_name: "Person",
join_table: "children_parents",
foreign_key: "child_id",
association_foreign_key: "parent_id",
optional: true
end
If it isn't apparent in the above model - in addition to the people table in the database, there is also a children_parents join table with two foreign key index fields child_id and parent_id. This allows us to represent the many-to-many relationship between children and parents.
I want to query for siblings of a person, so I added the following method to the Person model:
def siblings
self.parents.map do |parent|
parent.children.reject { |child| child.id == self.id }
end.flatten.uniq
end
However, this makes three SQL queries:
Person Load (1.0ms) SELECT "people".* FROM "people" INNER JOIN "children_parents" ON "people"."id" = "children_parents"."parent_id" WHERE "children_parents"."child_id" = $1 [["child_id", 3]]
Person Load (0.4ms) SELECT "people".* FROM "people" INNER JOIN "children_parents" ON "people"."id" = "children_parents"."child_id" WHERE "children_parents"."parent_id" = $1 [["parent_id", 1]]
Person Load (0.4ms) SELECT "people".* FROM "people" INNER JOIN "children_parents" ON "people"."id" = "children_parents"."child_id" WHERE "children_parents"."parent_id" = $1 [["parent_id", 2]]
I know that it is possible to make this a single SQL query like so:
SELECT DISTINCT(p.*) FROM people p
INNER JOIN children_parents cp ON p.id = cp.child_id
WHERE cp.parent_id IN ($1, $2)
AND cp.child_id != $3
$1 and $2 are the parent ids of the person, and $3 is the person id.
Is there a way to do this query using ActiveRecord?

You can use something like this:
def siblings
Person.select('siblings.*').from('people AS siblings').where.not(id: id)
.where(
parents.joins(
'JOIN children_parents ON parent_id = people.id AND child_id = siblings.id'
).exists
)
end
Here you can see few strange things:
from to set table alias. And you should avoid this, because after such table aliasing active record will not help any more with column names from ruby: where(column: value).order(:column) - will not work, only plain sql strings are left
exists - I use it very often instead of joins. When you are joining many records to one, you are receiving duplicates, then comes distinct or group and new problems with them. Exists also gives isolation of query: table and columns in EXISTS expression are invisible for other parts of query. Bad part of using it in rails: at least 1 plain SQL is needed.
One weakness of this method: if you will call it for each record somewhere, then you will have 1 query for each record - N+1 problem.
Now few words about The Rails Way. Rails guide suggests to always use has_many :through instead of habtm, I seen it here: https://github.com/rubocop-hq/rails-style-guide.
Rails ideology as I understood it stands for speed of development and simplicity of maintenance. First means that performance does not matter (just imagine how many users you need to start have issues with it), second says that flexibility of plain SQL is good, but not in rails, in rails please make code as simple as possible (see rubocop defaults: 10 loc in method, 100 loc in class, and 4 complexity metrics always saying that your code is too complex). I mean, many real world rails projects are making queries with N+1, making ineffective queries, and this rarely becomes a problem
So, in such cases I would recommend to try includes, preload, eager_load.

Related

Count has_many records of query

If I have a Model with a has_many relationship, how can I retrieve all of the records that all of the records in my query point to?
Let's just say, buildings have a has_many relationship with rooms. Here's what I want to do:
Building.where(...query...).rooms.count
This is just an example. I might want to count them, or I might want an ActiveRecord of the rooms that belong to the buildings that match the query.
One way is this, but I'm wondering if there's a better way:
building_ids = Building.where(...query...).pluck(:id)
Room.where(building_id: building_ids).count
Using select instead of pluck will result in a single sql statement instead of two separate ones.
building_ids = Building.where(...).ids
Room.where(building_id: building_ids)
you can also use join
Room.joins(:building).where(building: { name: 'somename' })
I'd use the sum of the counter caches.
Add the counter cache colum in a migration:
add_column :buildings, :rooms_count, default: 0
Update the relation in Room:
belongs_to :building, counter_cache: true
Then you could do something like:
Building.sum(:rooms_count)
That'll avoid n+1 queries
More details here https://blog.appsignal.com/2018/06/19/activerecords-counter-cache.html

How to implement complex SQL queries in Ruby on Rails?

I have four tables: "users", "user_groups", "groups", "categories".
"users" and "groups" are many_to_many relations through "user_groups".
"groups" and "categories" are many_to_one relations.
I created the following SQL query, but I'm not sure how to implement it in Ruby on Rails:
SELECT u.*
FROM users u
WHERE EXISTS (SELECT 1
FROM user_groups ug,
groups g,
categories c
WHERE u.id = ug.user_id
AND ug.group_id = g.id
AND g.category_id = c.id
AND c.id in ('1, 2, 3'))
What is the best way to implement it without using raw SQL in Ruby on Rails?
I am not a big fan of always translating complex queries into the ActiveRecord query language. Instead, I think it is perfectly fine to write complex queries in plain SQL because SQL is usually easier to write and to understand.
That said, I think this might work:
User.where(
id: UserGroup.select('user_groups.user_id')
.joins(groups: :categories)
.where(categories: { id: [1, 2, 3] })
)
This is probably a lot less complicated than you think:
class User
has_many :user_groups
has_many :groups, through: :user_groups
end
class Group
has_many :user_groups
has_many :users, through: :user_groups
belongs_to :category
end
User.joins(:groups)
.where(groups: { category_id: [1,2,3] })
ActiveRecord will handle joining the intermediate table.
Since this is a left inner join any rows without a match in the join table are omitted. No need to do WHERE EXISTS .... This will give you users that belong to at least one group with the category 1, 2 or 3.

How to simply join n-to-n association in rails?

I have three tables: users, locations, locations_users, The associations are as follows :
user has_many locations_users
user has_many locations :through => locations_users
location has_many locations_users
location has_many users :through => locations_users
How would I find all the users who are joined to location_id = 5 in one query?
Any help would be appreciated.
You can use LEFT OUTER JOIN. It fetches all data from the left table with matching data from right, if present (if not present, join column is null):
User
.joins('LEFT OUTER JOIN locations_users ON locations_users.user_id = users.id')
.where('locations_users.id IS NULL OR locations_users.location_id = ?', 5)
.all
Hovewer:
this query uses join, which's performance can be poor on big tables (maybe two queries will perform faster - test it!)
for better performance, make sure, that indexes are set on joined columns
I don't know 1 query way but the solution below is efficient.
a = User.where("id NOT IN (?)", LocationUser.pluck("DISTINCT user_id"))
b = LocationUser.where(location_id: 5).pluck("DISTINCT user_id")
result = User.where(id: a+b)

SQL query with multiple joins using Rails models as reference

I want to select a count of all surveys where the survey.property.address.city == "Garrison". I have the following models:
Survey
many_to_one :property
Property
one_to_many :surveys
many_to_one :address
Address
one_to_many :properties
How do I query using SQL?
SELECT count(*) FROM surveys JOIN...
Assuming that your table is named like rails would name those objects and you have the foreign keys that are implied by your relations:
SELECT
COUNT(*)
FROM
surveys
JOIN
properties ON surveys.property_id = properties.id
JOIN
addresses ON addresses.id = properties.address_id
WHERE
addresses.city = 'Garrison'
Also your relations are strangely defined... I'm assuming that that is just a psuedocode version to express the relations.
edit: I corrected the second join, because I believe I had the relations backwards.

Advanced SQL in Rails

I have 2 models
class User < AR
has_many :friends
end
class Friend < AR
# has a name column
end
I need to find all Users who are Friends with both 'Joe' and 'Jack'
Any idea how i can do this in rails?
One option is to put each of the names as arguments for individual INNER JOINS. In SQL it would be something like this:
SELECT users.* FROM users
INNER JOIN friends AS f1
ON users.id = f1.user_id
AND f1.name = 'Joe'
INNER JOIN friends AS f2
ON users.id = f2.user_id
AND f2.name = 'Jack'
Since it is INNER JOINS, it will only display results where the users table can be joined with both f1 and f2.
And to use it in Rails, maybe do it something like this:
class User < AR
has_many :friends
def self.who_knows(*friend_names)
joins((1..friend_names.length).map{ |n|
"INNER JOIN friends AS f#{n} ON users.id = f#{n}.user_id AND f#{n}.name = ?" }.join(" "),
*friend_names)
})
end
end
Which you then can call like this:
#users = User.who_knows("Joe", "Jack")
Possible way: User.all(:joins => :friends, :conditions => ["friends.name IN (?,?)", "Joe", "Jack"], :group => "users.id") and then iterate over the array to find users with 2 friends.
This is the best solution i got when tried to solve similar problem for myself. If you find the way to do it in pure sql or ActiveRecord – let me know please!
Although using hard-coded SQL as suggested by DanneManne will most often work, and is probably the way you'd want to go, it is not necessarily composable. As soon as you have hard-coded a table name, you can run into problems combining that into other queries where ActiveRecord may decide to alias the table.
So, at the cost of some extra complexity, we can solve this using some ARel as follows:
f = Friend.arel_table
User.
where(:id=>f.project(:user_id).where(f[:name].eq('Joe'))).
where(:id=>f.project(:user_id).where(f[:name].eq('Jack')))
This will use a pair of subqueries to do the job.
I'm fairly certain there's an ARel solution using joins as well, but and I can figure out how to compose that query in ARel, just not how to then use that query as the basis for an ActiveRecord query to get back User model instances.