Merge has_many and has_many through queries together - ruby-on-rails

This seems like a typical problem, but it is difficult to search for.
I want to select projects that a user owns via a has_many and projects that a user is associated to via a has_many through.
Consider the following models:
class User < ActiveRecord::Base
has_many :projects,
inverse_of: :owner
has_many :project_associations,
class_name: 'ProjectUser',
inverse_of: :user
has_many :associated_projects,
through: :project_associations,
source: :project
end
class Project < ActiveRecord::Base
belongs_to :owner,
class_name: 'User',
foreign_key: :owner_id,
inverse_of: :projects
has_many :user_associations,
class_name: 'ProjectUser',
inverse_of: :project
has_many :associated_users,
through: :user_associations,
source: :user
end
class ProjectUser < ActiveRecord::Base
belongs_to :project,
inverse_of: :user_associations
belongs_to :user,
inverse_of: :project_associations
end
It is trivial to do this with multiple queries:
user = User.find(1)
all_projects = user.projects + user.associated_projects
But I suspect it could be optimised using Arel into a single query.
Edit:
My first attempt at a solution using the find_by_sql method is:
Project.find_by_sql([
'SELECT "projects".* FROM "projects" ' \
'INNER JOIN "project_users" ' \
'ON "projects"."id" = "project_users"."project_id" ' \
'WHERE "project_users"."user_id" = :user_id ' \
'OR "projects"."owner_id" = :user_id',
{ user_id: 1 }
])
This produces the result I am expecting, but I would like to avoid using find_by_sql and instead let Arel build the SQL.

This should work:
Project.joins(:user_associations)
.where("projects.owner_id = ? OR project_users.user_id = ?", id, id)
You could put that in a method called User#all_projects.
You could also use UNION, although it seems like overkill here.
Just one warning: In my experience this structure of two alternate paths to connect your models, where one path is a join table and the other is a direct foreign key, causes a lot of trouble. You're going to have to deal with both cases all the time, and in complex queries the OR can confuse the database query planner. If you're able to, you might want to reconsider. I think you will have fewer headaches if you remove the owner_id column and identify owners with a role column on project_users instead.
EDIT or in Arel:
Project.joins(:user_associations)
.where(Project.arel_table[:owner_id].eq(id).or(
ProjectUser.arel_table[:user_id].eq(id)))

You can use the includes directive as in:
User.includes(:projects, :associated_projects).find(1)
Check http://apidock.com/rails/ActiveRecord/QueryMethods/includes
Then if you call projects and associated_projects on the found instance you'll not fire additional queries.

I have my solution, the model structure remains the same, and I add a method on the User model called all_projects.
class User < ActiveRecord::Base
has_many :projects,
inverse_of: :owner
has_many :project_associations,
class_name: 'ProjectUser',
inverse_of: :user
has_many :associated_projects,
through: :project_associations,
source: :project
def all_projects
query = Project.arel_table[:owner_id].eq(id).or(ProjectUser.arel_table[:user_id].eq(id))
Project.joins(:user_associations).where(query)
end
end
Calling #all_projects builds and executes this query:
SELECT "projects".*
FROM "projects"
INNER JOIN "project_users" ON "project_users"."project_id" = "products"."id"
WHERE ("projects"."owner_id" = '1' OR "project_users"."user_id" = '1')
The solution isn't as elegant as I would like, ideally I would like to replace Project.arel_table[:owner_id].eq(id) with the query that is built from the has_many projects association, adding the .or(ProjectUser.arel_table[:user_id].eq(id)) onto it, but this works well for now.

Related

in rails, how to return results from two has_many associations under another has_many association or a scope

I have the following:
has_many :matches_as_mentor, foreign_key: :mentor_id, class_name: 'Match'
has_many :matches_as_mentee, foreign_key: :mentee_id, class_name: 'Match'
and I need to have an association :matches that is made up of both of these combined together.
what is a good way to do this?
Unfortunely this is not really a case that ActiveRecord assocations really can handle that well. Assocations just link a single foreign key on one model to a primary key on another model and you can't just chain them together.
If you wanted to join both you would need the following JOIN clause:
JOINS matches ON matches.mentor_id = entities.id
OR matches.mentee_id = entities.id
You just can't get that as assocations are used in so many ways and you can't just tinker with the join conditons. To create a single assocation that contains both categories you need a join table:
class Entity < ApplicationRecord
# Easy as pie
has_many :matchings
has_many :matches, through: :matchings
# this is where it gets crazy
has_many :matchings_as_mentor,
class_name: 'Matching',
->{ where(matchings: { role: :mentor }) }
has_many :matches_as_mentor,
class_name: 'Match',
through: :matchings_as_mentor
has_many :matchings_as_mentee,
class_name: 'Matching',
->{ where(matchings: { role: :mentee }) }
has_many :matches_as_mentee,
class_name: 'Match',
through: :matchings_as_mentor
end
class Matching < ApplicationRecord
enum role: [:mentor, :mentee]
belongs_to :entity
belongs_to :match
validates_uniqueness_of :entity_id, scope: :match_id
validates_uniqueness_of :match_id, scope: :role
end
class Match < ApplicationRecord
# Easy as pie
has_many :matchings
has_many :entities, through: :matching
# this is where it gets crazy
has_one :mentor_matching,
class_name: 'Matching',
->{ where(matchings: { role: :mentee }) }
has_one :mentor, through: :mentor_matching, source: :entity
has_one :mentee_matching,
class_name: 'Matching',
->{ where(matchings: { role: :mentor }) }
has_one :mentee, through: :mentor_matching, source: :entity
end
And presto you can reap the rewards of having a homogenous association:
entities = Entity.includes(:matches)
entities.each do |e|
puts e.matches.order(:name).inspect
end
It is a lot more complex though and you need validations and constraints to ensure that a match can only have one mentor and mentee. its up to you to evaluate if its worth it.
The alternative is doing somehing like:
Match.where(mentor_id: entity.id)
.or(Match.where(mentee_id: entity.id))
Which cannot does not allow eager loading so it would cause a N+1 query issue if you are displaying a list of entites and their matches.
You have several different ways to achieve this. First one is simple
[*entity.matches_as_mentor, *entity.matches_as_mentee]
Second one is a little bit complex. You need to use UNION or UNION ALL sql statement depends on your case. Here is the difference - What is the difference between UNION and UNION ALL?.
What you need is just build a SQL query to get these records.
Third one is to make include. Entity.includes(:matches_as_mentor, :matches_as_mentee).
Depends on how you need to process(show) it and how much records do you have in these tables you need which one to choose.
I prefer to use UNION ALL if you don't need duplicates. But also you can look for INNER JOIN.
One thing that i want to add is you can do smth like that
Match.where(mentor_id: entity.id).or(Match.where(mentee_id: entity.id))
The last one approach gives you the best perfomance.

Joining Nested Associations (Multiple Level)

I have the following models and relationships:
A User has many Offers (where he/she is the seller), an Offer has many Purchases, a Purchase has many Accbooks
Models and associations:
class User < ApplicationRecord
has_many :offers, foreign_key: :seller_id
has_many :purchases, foreign_key: :buyer_id
end
class Offer < ApplicationRecord
has_many :purchases
belongs_to :seller, class_name: 'User'
end
class Purchase < ApplicationRecord
belongs_to :offer
belongs_to :buyer, class_name: 'User'
has_one :seller, through: :offer
has_many :accbooks, class_name: 'Admin::Accbook', foreign_key: 'purchase_id'
end
module Admin
class Accbook < ApplicationRecord
belongs_to :purchase
end
end
I want to get all the Accbooks of any given user (as a seller). The equivalent SQL statement would look like this:
SELECT "accbooks".*
FROM "accbooks"
INNER JOIN "purchases" ON "purchases"."id" = "accbooks"."purchase_id"
INNER JOIN "offers" ON "offers"."id" = "purchases"."offer_id"
INNER JOIN "users" ON "users"."id" = "offers"."seller_id"
WHERE "users"."id" = ?
So far I've tried this:
Admin::Accbook.joins( {purchase: :offer} )
Which gives me this SQL as a result:
SELECT "accbooks".*
FROM "accbooks"
INNER JOIN "purchases" ON "purchases"."id" = "accbooks"."purchase_id"
INNER JOIN "offers" ON "offers"."id" = "purchases"."offer_id"
Now I donĀ“t know how to add the join to the User model, and then how to add the Where condition.
Thanks for any insight.
You can joins the relations together and apply where clause on the joined relations:
Admin::Accbook
.joins(purchase: :offer)
.where(offers: { seller_id: 123 })
A thing to know, where uses the DB table's name. joins (and includes, eager_load, etc) uses the relation name. This is why we have:
Admin::Accbook
.joins(purchase: :offer)
# ^^^^^ relation name
.where(offers: { seller_id: 123 })
# ^^^^^^ table name
Try Adding following association in users.rb
has_many :accbooks, through: :purchases
So your problem is user is acting as 2 roles for same accounts. You can try something like below stuff
class User < ApplicationRecord
has_many :offers, foreign_key: :seller_id
has_many :purchases, foreign_key: :buyer_id
has_many :offers_purchases,
through: :offers,
:class_name => 'Purchase',
:foreign_key => 'offer_id',
:source => :purchases
end

Eager load polymorphic associations

I have the following models:
class Conversation < ActiveRecord::Base
belongs_to :sender, foreign_key: :sender_id, polymorphic: true
belongs_to :receiver, foreign_key: :receiver_id, polymorphic: true
.
class Owner < ActiveRecord::Base
.
class Provider < ActiveRecord::Base
.
class Account < ActiveRecord::Base
has_one :provider, dependent: :destroy
has_many :owners, dependent: :destroy
So in a Conversation, sender can be an Owner or a Provider. With this in mind, I can make queries like:
Conversation.includes(sender: :account).limit 5
This works as intended. The problem is when I want to use a where clause in associated model Account. I want to filter conversations which associated account's country is 'US'. Something like this:
Conversation.includes(sender: :account).where('accounts.country' => 'US').limit 5
But this wont work, I get the error ActiveRecord::EagerLoadPolymorphicError: Cannot eagerly load the polymorphic association :sender
What is the correct way of doing this kind of query?
I've also tried to use joins, but I get the same error.
I've found myself a solution, in case anyone else is interested. I ended up using joins with a SQL query:
Conversation.joins("LEFT JOIN owners ON owners.id = conversations.sender_id AND conversations.sender_type = 'Owner'
LEFT JOIN providers ON providers.id = conversations.sender_id AND conversations.sender_type = 'Provider'
LEFT JOIN accounts ON accounts.id = owners.account_id OR accounts.id = providers.account_id").
where('accounts.country' => 'US').limit 5

How to set a 'has many :through' a polymorphic association

I have defined my models as follows. I am trying to do #user.orders using has_many. I have defined a method:orders to show the behaviour I want.
class Location < ActiveRecord::Base
belongs_to :locationable, polymorphic: true
has_many :orders, ->(location) { where(location.locationable_type == 'User') }, class_name: 'Order', foreign_key: :drop_location_id
# but this does not check for type
# it produces the SQL: SELECT "orders".* FROM "orders" WHERE "orders"."drop_location_id" = $1
end
class Order < ActiveRecord::Base
# location is polymorphic, so type of location can be merchant/user
belongs_to :pickup_location, class_name: 'Location'
belongs_to :drop_location, class_name: 'Location'
end
class User < ActiveRecord::Base
has_many :locations, as: :locationable
# TODO: make it a has_many :orders instead
def orders
Order.where(drop_location: locations)
end
end
Using a method doesn't feel like the rails way. Moreover, I want it to work well with rails_admin.
By now, you should have received an error indicating that you can't use has_many through a polymorphic association. If you think about it, it makes perfect sense, how could your ORM (ActiveRecord) formulate the query as the joins would be horrendous.

"has_many :through" association through a polymorphic association with STI

I have two models that use the people table: Person and Person::Employee (which inherits from Person). The people table has a type column.
There is another model, Group, which has a polymorphic association called :owner. The groups table has both an owner_id column and an owner_type column.
app/models/person.rb:
class Person < ActiveRecord::Base
has_one :group, as: :owner
end
app/models/person/employee.rb:
class Person::Employee < Person
end
app/models/group.rb:
class Group < ActiveRecord::Base
belongs_to :owner, polymorphic: true
belongs_to :supervisor
end
The problem is that when I create a Person::Employee with the following code, the owner_type column is set to an incorrect value:
group = Group.create
=> #<Group id: 1, owner_id: nil, owner_type: nil ... >
group.update owner: Person::Employee.create
=> true
group
=> #<Group id: 1, owner_id: 1, owner_type: "Person" ... >
owner_type should be set to "Person::Employee", but instead it is set to "Person".
Strangely, this doesn't seem to cause any problems when calling Group#owner, but it does cause issues when creating an association like the following:
app/models/supervisor.rb:
class Supervisor < ActiveRecord::Base
has_many :groups
has_many :employees, through: :groups, source: :owner,
source_type: 'Person::Employee'
end
With this type of association, calling Supervisor#employees will yield no results because it is querying for WHERE "groups"."owner_type" = 'People::Employees' but owner_type is set to 'People'.
Why is this field getting set incorrectly and what can be done about it?
Edit:
According to this, the owner_type field is not getting set incorrectly, but it is working as designed and setting the field to the name of the base STI model.
The problem appears to be that the has_many :through association searches for Groups with a owner_type set to the model's own name, instead of the base model's name.
What is the best way to set up a has_many :employees, through: :group association that correctly queries for Person::Employee entries?
You're using Rails 4, so you can set a scope on your association. Your Supervisor class could look like:
class Supervisor < ActiveRecord::Base
has_many :groups
has_many :employees, lambda {
where(type: 'Person::Employee')
}, through: :groups, source: :owner, source_type: 'Person'
end
Then you can ask for a supervisor's employees like supervisor.employees, which generates a query like:
SELECT "people".* FROM "people" INNER JOIN "groups" ON "people"."id" =
"groups"."owner_id" WHERE "people"."type" = 'Person::Employee' AND
"groups"."supervisor_id" = ? AND "groups"."owner_type" = 'Person'
[["supervisor_id", 1]]
This lets you use the standard association helpers (e.g., build) and is slightly more straightforward than your edit 2.
I did came up with this workaround, which adds a callback to set the correct value for owner_type:
class Group < ActiveRecord::Base
belongs_to :owner, polymorphic: true
before_validation :copy_owner_type
private
def copy_owner_type
self.owner_type = owner.type if owner
end
end
But, I don't know if this is the best and/or most elegant solution.
Edit:
After finding out that the owner_type field is supposed to be set to the base STI model, I came up with the following method to query for Person::Employee entries through the Group model:
class Supervisor < ActiveRecord::Base
has_many :groups
def employees
Person::Employee.joins(:groups).where(
'people.type' => 'Person::Employee',
'groups.supervisor_id' => id
)
end
end
However, this does not seem to cache its results.
Edit 2:
I came up with an even more elegant solution involving setting the has_many :through association to associate with the base model and then creating a scope to query only the inherited model.
class Person < ActiveRecord::Base
scope :employees, -> { where type: 'Person::Employee' }
end
class Supervisor < ActiveRecord::Base
has_many :groups
has_many :people, through: :groups, source: :owner, source_type: 'Person'
end
With this I can call Supervisor.take.people.employees.

Resources