Join two tables together in Ruby/Rails active record - ruby-on-rails

I'm new to Rails and I'm not sure how to do this:
I'm writing a rake script that needs to reference off of a value from two tables:
The owner_id in the Animals table references the Owner table:
Animals:[
{id:1, name:"Bow wow", owner_id:1, score:null},
{id:2, name:"Chiaow", owner_id:2, score:null},
{id:3, name:"Fishbob and Ben", owner_id:9, score:null}
]
Owner:[
{id:1, name:"Doug"},
{id:2, name:"Michelle"},
{id:9, name:"Ben"}
]
I would like to combine both these tables to get a result that looks like this:
Combined = [
{id:1, score:null, keywords:"Bow Wow Doug", name:"Bow wow", owner:"Doug"},
{id:2, score:null, keywords:"Chiaow Michelle", name:"Chiaow, owner:Michelle",
{id:3, score:null, keywords:"Fishbob and Ben", name:"Fishbob", owner:"Ben"}
]
I want to also index through the keywords using
combined_list = Combined.where("keywords LIKE (?)", "%#{chosen_word}%")
Is this possible?

If you join two models (Animal and Owner) you can select which attributes from the joined table you want to make available in the instances:
combined = Animal.joins(:owner).select(:id, :name, 'owners.name as owner_name')
puts combined.first.name, combined.first.owner_name
This implies of course, that the Animal model belongs_to :owner.
To filter this relation I would do this:
cw = "%#{chosen_word}%"
combined.where('animals.name LIKE ?', cw).
or(combined.where('owners.name LIKE ?', cw))

Related

Select records all of whose records exist in another join table

In the following book club example with associations:
class User
has_and_belongs_to_many :clubs
has_and_belongs_to_many :books
end
class Club
has_and_belongs_to_many :users
has_and_belongs_to_many :books
end
class Book
has_and_belongs_to_many :users
has_and_belongs_to_many :clubs
end
given a specific club record:
club = Club.find(params[:id])
how can I find all the users in the club who have all books in array of books?
club.users.where_has_all_books(books)
In PostgreSQL it can be done with a single query. (Maybe in MySQL too, I'm just not sure.)
So, some basic assumptions first. 3 tables: clubs, users and books, every table has id as a primary key. 3 join tables, books_clubs, books_users, clubs_users, each table contains pairs of ids (for books_clubs it will be [book_id, club_id]), and those pairs are unique within that table. Quite reasonable conditions IMO.
Building a query:
First, let's get ids of books from given club:
SELECT book_id
FROM books_clubs
WHERE club_id = 1
ORDER BY book_id
Then get users from given club, and group them by user.id:
SELECT CU.user_id
FROM clubs_users CU
JOIN users U ON U.id = CU.user_id
JOIN books_users BU ON BU.user_id = CU.user_id
WHERE CU.club_id = 1
GROUP BY CU.user_id
Join these two queries by adding having to 2nd query:
HAVING array_agg(BU.book_id ORDER BY BU.book_id) #> ARRAY(##1##)
where ##1## is the 1st query.
What's going on here: Function array_agg from the left part creates a sorted list (of array type) of book_ids. These are books of user. ARRAY(##1##) from the right part returns the sorted list of books of the club. And operator #> checks if 1st array contains all elements of the 2nd (ie if user has all books of the club).
Since 1st query needs to be performed only once, it can be moved to WITH clause.
Your complete query:
WITH club_book_ids AS (
SELECT book_id
FROM books_clubs
WHERE club_id = :club_id
ORDER BY book_id
)
SELECT CU.user_id
FROM clubs_users CU
JOIN users U ON U.id = CU.user_id
JOIN books_users BU ON BU.user_id = CU.user_id
WHERE CU.club_id = :club_id
GROUP BY CU.user_id
HAVING array_agg(BU.book_id ORDER BY BU.book_id) #> ARRAY(SELECT * FROM club_book_ids);
It can be verified in this sandbox: https://www.db-fiddle.com/f/cdPtRfT2uSGp4DSDywST92/5
Wrap it to find_by_sql and that's it.
Some notes:
ordering by book_id is not necessary; #> operator works with unordered arrays too. I just have a suspicion that comparison of ordered array is faster.
JOIN users U ON U.id = CU.user_id in 2nd query is only necessary for fetching user properties; in case of fetching user ids only it can be removed
It appears to work by grouping and counting.
club.users.joins(:books).where(books: { id: club.books.pluck(:id) }).group('users.id').having('count(*) = ?', club.books.count)
If anyone knows how to run the query without intermediate queries that would be great and I will accept the answer.
This looks like a situation where you'd make two queries, one to get all the ids you need, the other select perform a WHERE IN.

Ruby on Rails - Has Many, Through: find multiple conditions

I realised its quite difficult to explain my problem with only words, so i'm going to use an example to describe what i am trying to do instead.
So for example:
#model Book
has_many: book_genres
has_many: genres, through: :book_genres
#model Genre
has_many: book_genres
has_many: books, through: :book_genres
So finding books that belong to one genre only would be relatively straightforward, such as:
#method in books model
def self.find_books(genre)
#g = Genre.where('name LIKE ?' , "#{genre}").take
#b = #g.books
#get all the books that are of that genre
end
So in rails console i can do Book.find_books("Fiction") and then i would get all the books that are of fiction genre.
But how can i find all the books that are both "Young Adult" and "Fiction" ? Or what if i would like to query for books that have 3 genres, such as "Young Adult", "Fiction" and "Romance" ?
I could do g = Genre.where(name: ["Young Adult", "Fiction", "Romance"]) but subsequent to that i cannot do g.books and get all the books that are related to this 3 genres.
I am actually quite bad with active record so im not even sure if theres a better way to query through Books directly instead of finding Genre then finding all books that are associated with it.
But what i cannot wrap my head around is how do i get all the books that have multiple (specific)genres?
UPDATE:
So the current answers provided Book.joins(:genres).where("genres.name" => ["Young Adult", "Fiction", "Romance"]) works, but the problem is it returns all books that has the genre of Young Adult OR Fiction OR Romance.
What query do i pass so that the books return has ALL 3 Genres and not only 1 or 2 out of the 3?
Matching any of the given genres
The following should work for both an Array and a String:
Book.joins(:genres).where("genres.name" => ["Young Adult", "Fiction", "Romance"])
Book.joins(:genres).where("genres.name" => "Young Adult")
In general, it's better to pass a Hash to where, rather than trying to write a SQL snippet yourself.
See the Rails Guides for more details:
http://guides.rubyonrails.org/active_record_querying.html#hash-conditions
http://guides.rubyonrails.org/active_record_querying.html#specifying-conditions-on-the-joined-tables
Matching all of the given genres with one query
A single query could be built and then passed to .find_by_query:
def self.in_genres(genres)
sql = genres.
map { |name| Book.joins(:genres).where("genres.name" => name) }.
map { |relation| "(#{relation.to_sql})" }.
join(" INTERSECT ")
find_by_sql(sql)
end
This means that calling Book.in_genres(["Young Adult", "Fiction", "Romance"]) will run a query that looks something like this:
(SELECT books.* FROM books INNER JOIN … WHERE genres.name = 'Young Adult')
INTERSECT
(SELECT books.* FROM books INNER JOIN … WHERE genres.name = 'Fiction')
INTERSECT
(SELECT books.* FROM books INNER JOIN … WHERE genres.name = 'Romance');
It has the upside of letting the database do the heavy lifting of combining the result sets.
The downside is that we're using raw SQL, so we can't chain this with other ActiveRecord methods, for example Books.order(:title).in_genres(["Young Adult", "Fiction"]) will ignore the ORDER BY clause we've tried to add.
We're also manipulating SQL queries as strings. It's possible we could avoid this using Arel, but the way Rails and Arel handle binding query values makes this pretty complicated.
Matching all of the given genres with multiple query
It's also possible to use multiple queries:
def self.in_genres(genres)
ids = genres.
map { |name| Book.joins(:genres).where("genres.name" => name) }.
map { |relation| relation.pluck(:id).to_set }.
inject(:intersection).to_a
where(id: ids)
end
This means that calling Book.in_genres(["Young Adult", "Fiction", "Romance"]) will run four queries that look something like this:
SELECT id FROM books INNER JOIN … WHERE genres.name = 'Young Adult';
SELECT id FROM books INNER JOIN … WHERE genres.name = 'Fiction';
SELECT id FROM books INNER JOIN … WHERE genres.name = 'Romance';
SELECT * FROM books WHERE id IN (1, 3, …);
The downside here is that for N genres, we're making N+1 queries. The upside is that this can be combined with other ActiveRecord methods; Books.order(:title).in_genres(["Young Adult", "Fiction"]) will do our genre filtering, and sort by title.
I didn't try this but I think it will work
Book.joins(:genres).where("genres.name IN (?)", ["Young Adult", "Fiction", "Romance"])
Here is how I would do it in SQL:
SELECT *
FROM books
WHERE id IN (
SELECT bg.book_id
FROM book_genres bg
INNER JOIN genres g
ON g.id = bg.genre_id
WHERE g.name LIKE 'Young Adult'
INTERSECT
SELECT bg.book_id
FROM book_genres bg
INNER JOIN genres g
ON g.id = bg.genre_id
WHERE g.name LIKE 'Fiction'
INTERSECT
...
)
The inner query will contain only books belonging to all the genres you ask about.
Here is how I'd do it in ActiveRecord:
# book.rb
def self.in_genres(genre_names)
subquery = genre_names.map{|n|
<<-EOQ
SELECT bg.book_id
FROM book_genres bg
INNER JOIN genres g
ON g.id = bg.genre_id
WHERE g.name LIKE ?
EOQ
}.join("\nINTERSECT\n")
where(<<-EOQ, *genre_names)
id IN (
#{subquery}
)
EOQ
end
Note that I am using ? to avoid sql injection vulnerabilities, which is a problem in the code you proposed in your question.
Another approach would be to use multiple EXISTS conditions with correlated sub-queries:
SELECT *
FROM books
WHERE EXISTS (SELECT 1
FROM book_genres bg
INNER JOIN genres g
ON g.id = bg.genre_id
WHERE g.name LIKE 'Young Adult'
AND bg.book_id = books.id)
AND EXISTS (SELECT 1
FROM book_genres bg
INNER JOIN genres g
ON g.id = bg.genre_id
WHERE g.name LIKE 'Fiction'
AND bg.book_id = books.id)
AND ...
You'd construct this query in ActiveRecord similarly to the first approach. I'm not sure which would be faster, so you could try both if you like.
Here is yet another way to do the SQL---possibly fastest:
SELECT *
FROM books
WHERE id IN (
SELECT bg.book_id
FROM book_genres bg
INNER JOIN genres g
ON g.id = bg.genre_id
WHERE (g.name LIKE 'Young Adult' OR g.name LIKE 'Fiction' OR ...)
GROUP BY bg.book_id
HAVING COUNT(DISTINCT bg.genre_id) >= 2 -- or 3, or whatever
)

Rails order results with multiple joins to same table

--Edit--
I wanted to simplify this question.
With this model structure:
has_one :pickup_job, class_name: 'Job', source: :job
has_one :dropoff_job, class_name: 'Job', source: :job
What I want to do is:
Package.joins(:dropoff_job, :pickup_job).order(pickup_job: {name: :desc})
This is obviously not valid, the actual syntax that should be used is:
.order('jobs.name desc')
However the way that rails joins the tables means that I cannot ensure what the table alias name will be (if anything), and this will order by dropoff_job.name instead of pickup_job.name
irb(main):005:0> Package.joins(:dropoff_job, :pickup_job).order('jobs.name desc').to_sql
=> "SELECT \"packages\".* FROM \"packages\" INNER JOIN \"jobs\" ON \"jobs\".\"id\" = \"packages\".\"dropoff_job_id\" INNER JOIN \"jobs\" \"pickup_jobs_packages\" ON \"pickup_jobs_packages\".\"id\" = \"packages\".\"pickup_job_id\" ORDER BY jobs.name desc"
Also I am not in control of how the tables are joined, so I cannot define the table alias.
--UPDATE--
I have had a play with trying to extract the alias names from the current scope using something like this:
current_scope.join_sources.select { |j| j.left.table_name == 'locations' }
But am still a little stuck and really feel like there should be a MUCH simpler solution.
--UPDATE--
pan's answer works in some scenarios but I am looking for a solution that is a bit more dynamic.
Use the concatenation of association name in plural and current table name as a table alias name in the order method:
Package.joins(:dropoff_job, :pickup_job).order('pickup_jobs_packages.name desc')
You can try something like this
Package.joins("INNER JOIN locations as dropoff_location ON dropoff_location.id = packages.dropoff_location_id INNER JOIN locations as pickup_location ON pickup_location.id = packages.pickup_location_id)
Idea is to create your own alias while joining the table

rails 3 order by another models column

I have 2 models in my Rails 3 app which I use to describe people and where they live
Unfortunately I set these up without using associations
The 2 tables are setup like this
People
id
name
location_id
Locations
id
name
what I want to do is list all entries in the Peoples table ordered by Locations.name alphabetically and People.name alphabetically
I can do a simple sort using this code which groups each person by a location but I need to drill into the Locations table as well
#people = People.all(:order => '"location_id" ASC, "name" ASC')
Anyone have any idea?
Also is it a good idea to set up an association in the People class to say location_id is Locations.id
Add
belongs_to :location
To the People class
Then you can query the following way:
#people = People.joins(:location).order("locations.name ASC, people.name ASC")

Rails basic search and PostgreSQL

I'm trying to create basic search in my web app. Here's code of search function.
def self.search(title, category_id, city_id)
if title || category_id || city_id
joins(:category).where('title LIKE (?) AND category.category_id IN (?) AND city.city_id IN (?)', "%#{title}%", "%#{category_id}%", "%#{city_id}%")
else
scoped
end
end
I have these associations in my model:
has_one :category
has_one :city
And I get this error
ActionView::Template::Error (PG::Error: ERROR: missing FROM-clause entry for ta
ble "category"
LINE 1: ..._id" = "events"."id" WHERE (title LIKE ('%%') AND category.c...
I'm using PostgreSQL. What I can do to remove this error?
The form of joins that you're using wants the association name, the SQL wants the table name. The table should be called categories.
A few other things:
I don't see you joining :city anywhere so your next error will be "Missing FROM-clause entry for table "city". The solution will be to .joins(:city) and use cities in the where. But keep reading anyway.
You don't need the parentheses around the value for LIKE, just title LIKE ? is fine.
You're using IN expressions for the category and city but you're giving them LIKE patterns and that won't work: the IDs will be numbers and you can use LIKE with numbers. If you're using IN then you'll usually want to supply a list of possible values, if you only want to match one value then just use = and a single value for the placeholder.
The categories table probably doesn't have a category_id column, similarly for the cities table and city_id column. Those two columns should be in your model's table.
Searching for a title when you don't have a title doesn't make much sense. Similarly for country and city.
That looks like a lot of problems but they can be fixed without too much effort:
def self.search(title, category_id, city_id)
rel = scoped
rel = rel.where('title like ?', "%#{title}%") if(title)
rel = rel.where('category_id = ?', category_id) if(category_id)
rel = rel.where('city_id = ?', city_id) if(city_id)
rel
end
and you don't even need joins or explicit table names at all.

Resources