My Problem
Imagine I've got those models:
class Absence << ApplicationRecord
belongs_to :user
end
class Vacation << Absence
belongs_to :vacation_contingent
end
class Illness << Absence; end
Now I want to retrieve all absences with
absences = Absence.where(user: xxx)
and iterate over the vacation contingents
vacations = absences.select { |absence| absence.is_a?(Vacation)
vacations.each { |vacation| puts vacation.vacation_contingent.xxx }
Now I've got 1 database query for those absences and 1 for each vacation_contingent -> bad
PS: I use Absence.where instead of Vacation.where because I want to do other things with those absences.
What I tried
Of course
Absence.where(user: xxx).includes(:vacation_contingent)
# -> ActiveRecord::AssociationNotFoundError Exception: Association named 'vacation_contingent' was not found`
vacations = Vactions.where(user: xxx).includes(:vacation_contingent)
other_absences = Absence.where(user: xxx).where.not(type: 'Vacation')
But this one is ugly and I've got 1 database query more than I want to because I'm fetchting the absences 2 times.
3.
absences = Absence.where(user: xxx)
vacations = absences.select { |absence| absence.is_a?(Vacation)
preloader = ActiveRecord::Associations::Preloader.new
preloader.preload(vacations, :vacation_contingent)
# -> ArgumentError Exception: missing keywords: :records, :associations
# -> (The initializer changed)
absences = Absence.where(user: xxx)
vacations = absences.select { |absence| absence.is_a?(Vacation)
preloader = ActiveRecord::Associations::Preloader.new(records: vacations, associations: %i[vacation_contingent])
# -> This line does nothing on it's own
preloader.call
# -> This just executes SELECT "vacation_contingents".* FROM "vacation_contingents" vacation.size times
preloader.preload
# -> Does the same as .call
# -> And this doesn't even preload anything. When executing
vacations.first.vacation_contingent
# -> then the database is being asked again
The solution 2 looks to me the best you can do with ActiveRecord.
If you want only one request, you could do it with raw SQL; Something like :
Absence.connection.select_all(%{SELECT *
FROM absences
LEFT OUTER JOIN vacation_contingents ON absences.vacation_contingents_id = vacation_contingents.id
WHERE absences.user_id = ?", user_xxx.id})
It will return ActiveRecord::Result with a line for each Absence, and both the columns of Absence and VacationContingent
Related
I have 2 models with a has_many association:
class Log < ActiveRecord::Base
has_many :log_details
end
and
class LogDetail < ActiveRecord::Base
belongs_to :log
end
The Log table has an action_type string column. The LogDetail table has 2 columns: key and value, both string, and a reference back to the Log table with a log_id.
I want to write 3 scopes on the Log model to query for some details joining the Log table with LogModel twice. Here's a sample:
has_many :payment_gateways, -> {where(key: 'payment_gateway')}, class_name: 'LogDetail', foreign_key: :log_id
has_many :coupon_codes, -> {where(key: 'coupon_code')}, class_name: 'LogDetail', foreign_key: :log_id
scope :initiate_payment, -> {where(action_type: 'INITIATE PAYMENT')}
scope :payment_gateway, -> (pg) {joins(:payment_gateways).where(log_details: {value: pg}) unless pg.blank?}
scope :coupon_code, -> (cc) {joins(:coupon_codes).where(log_details: {value: cc}) unless cc.blank?}
Using the above scopes, if I try to query for
Log.initiate_payment.payment_gateway('sample_pg').coupon_code('sample_cc')
I get the SQL query:
SELECT
`logs`.*
FROM
`logs`
INNER JOIN
`log_details` ON `log_details`.`log_id` = `logs`.`id`
AND `log_details`.`key` = 'payment_gateway'
INNER JOIN
`log_details` `coupon_codes_logs` ON `coupon_codes_logs`.`log_id` = `logs`.`id`
AND `coupon_codes_logs`.`key` = 'coupon_code'
WHERE
`logs`.`action_type` = 'INITIATE PAYMENT'
AND `log_details`.`value` = 'sample_pg'
AND `log_details`.`value` = 'sample_cc'
instead of: (notice the difference in the last AND condition)
SELECT
`logs`.*
FROM
`logs`
INNER JOIN
`log_details` ON `log_details`.`log_id` = `logs`.`id`
AND `log_details`.`key` = 'payment_gateway'
INNER JOIN
`log_details` `coupon_codes_logs` ON `coupon_codes_logs`.`log_id` = `logs`.`id`
AND `coupon_codes_logs`.`key` = 'coupon_code'
WHERE
`logs`.`action_type` = 'INITIATE PAYMENT'
AND `log_details`.`value` = 'sample_pg'
AND `coupon_codes_logs`.`value` = 'sample_cc'
The first query, because it doesn't resolve the join table references properly, gives me zero results.
How can I modify my scopes/models in such a way to generate the correct query? I think I need a reference to the join table alias inside the scope's where clause, but I'm not sure how to get that reference.
Sadly, ActiveRecord has no built-in way to specify the alias used when joining an association. Using merge to try merging the two scopes also fails as the condition is overridden.
3 solutions:
Use Arel to alias a joins, but that's a bit hard to read, and you still need to repeat the association definition for payment_gateways and coupon_codes
Join directly in SQL:
scope :payment_gateway, -> (pg) { joins(<<-SQL
INNER JOIN log_details payment_gateways
ON payment_gateways.log_id = logs.id
AND payment_gateways.key = 'payment_gateway'
AND payment_gateways.value = #{connection.quote(pg)}
SQL
) if pg.present? }
But you need to add manually the conditions already defined in the associations
Finally, my favorite, a solution that sticks to ActiveRecord:
scope :payment_gateway, -> (pg) do
where(id: unscoped.joins(:payment_gateways).where(log_details: {value: pg})) if pg.present?
end
scope :coupon_code, -> (cc) do
where(id: unscoped.joins(:coupon_codes).where(log_details: {value: cc})) if cc.present?
end
Gotcha #1: if you use Rails < 5.2, you might need to use class methods instead of scopes.
Gotcha #2: Solution #3 might be less performant than #2, make sure to EXPLAIN ANALYZE to see the difference.
I have two scopes that are shared by the majority of my models. They have raw SQL that directly refers to the model's table name, and that doesn't play nicely with Arel:
class ApplicationRecord < ActiveRecord::Base
valid = lambda do |positive = true|
if %w[validForBegin validForEnd].all? { |c| base_class.column_names.include?(c) }
condition = "NOW() BETWEEN #{base_class.table_name}.validForBegin AND #{base_class.table_name}.validForEnd"
condition = "!(#{condition})" unless positive
where(condition)
end
end
scope :valid, valid
scope :invalid, -> { valid(false) }
end
# Sample usage
class Party < ApplicationRecord
has_one :name,
-> { valid },
class_name: 'PartyName',
foreign_key: :partyId,
has_many :expired_names,
-> { invalid },
class_name: 'PartyName',
foreign_key: :partyId,
end
Since my scope refers directly to the model's table_name, I can't join on both associations at once:
Party.joins(:name, :expired_names).first
# Produces this sequel statement
SELECT `party`.*
FROM `party`
INNER JOIN `party_name` ON `party_name`.`partyId` = `party`.`id`
AND (NOW() BETWEEN party_name.validForBegin AND party_name.validForEnd)
INNER JOIN `party_name` `expired_names_party` ON `expired_names_party`.`partyId` = `party`.`id`
AND (!(NOW() BETWEEN party_name.validForBegin AND party_name.validForEnd))
ORDER BY `party`.`id` ASC LIMIT 1
Note that both 'AND' conditions on the joins are referring to the table party_name. The second one should instead be referring to expired_names_party, the dynamically generated table alias. For more complicated Rails queries where Arel assigns an alias to EVERY table, both joins will fail.
Is it possible for my scope to use the alias assigned to it by Arel at execution time?
I created this repo to help test your situation:
https://github.com/wmavis/so_rails_arel
I believe the issue is that you are trying to use the same class for both relationships. By default, rails wants to use the name of the class for the table associated with that class. Therefore, it is using the table name party_name for both queries.
To get around this issue, I created an ExpiredName class that inherits from PartyName but tells rails to use the expired_names table:
https://github.com/wmavis/so_rails_arel/blob/master/app/models/expired_name.rb
class ExpiredName < PartyName
self.table_name = 'expired_names'
end
This seems to fix the issue in my code:
Party.joins(:name, :expired_names).to_sql
=> "SELECT \"parties\".* FROM \"parties\"
INNER JOIN \"party_names\"
ON \"party_names\".\"party_id\" = \"parties\".\"id\"
INNER JOIN \"expired_names\"
ON \"expired_names\".\"party_id\" = \"parties\".\"id\""
Let me know if it doesn't work for you and I'll try to help.
Ok, I have 3 tables:
lessons : which has many new_words
new_words : which belongs_to lesson, and have a word_type column (Verb Adj Noun .etc)
verb_forms :
every new words which has word_type = Verb will have 1 verb_form associated with.
verb_forms has a column verb_type which indicated the type of verb (1,2 or 3)
So now, I want to query all lessons that has at least 1 Verb that has verb_type = 1
So far, I've archived this:
class VerbForm < ApplicationRecord
belongs_to :new_word
scope :first_type, -> { where(verb_type: 1) }
end
class NewWord < ApplicationRecord
has_one :verb_form, dependent: :destroy, inverse_of: :new_word
scope :ftv, -> { verbs.joins(:verb_form).merge(VerbForm.first_type) }
end
class Lesson < ApplicationRecord
has_many :new_words
scope :with_ftvs, -> { joins(:new_words).merge(NewWord.ftv) }
end
Now, if i run Lesson.with_ftvs, each lesson will appear multi times depend on number of first-type verbs in that lesson (for example, if there are 3 first-type verbs in lesson 1, then lesson 1 returned 3 times in above query)
So, how can I get distinct lessons list ?, I tried this query but it returned nil ?
scope :with_ftvs, -> { joins(:new_words).merge(NewWord.ftv).distinct }
p/s: I was always thinking that count and length are the same. But when I try it with my original query: Lesson.with_ftvs.count vs Lesson.with_ftvs.length, they returns diffirent values:
In SQL joins may return duplicated results so you should either use distinct or group by some column.
scope :with_ftvs, -> { joins(:new_words).merge(NewWord.ftv).group("lessons.id") }
# or
scope :with_ftvs, -> { joins(:new_words).merge(NewWord.ftv).distinct }
I'm using Rails 5 with a MySQL database and this is how my models look like:
class User < ApplicationRecord
has_many :user_activities
end
# Table: "users"
class UserActivity < ApplicationRecord
belongs_to :user
end
# Table: "user_activities"
To make it clear what I'd like to achieve, I'm going to show you an actually working query at first:
SELECT * FROM user_activities LEFT JOIN users ON users.id = user_activities.user_id WHERE (users.gender = 'female') LIMIT 1;
Nothing too special, right? The problem is that I don't really get this working in my Rails project. I read a few articles already but somehow I got stuck...
ua = UserActivity.includes(:user).where(users: {gender: 'female'}).limit(1) # <-- The "join" part of it works
# ua = UserActivity.includes(:user).where("users.gender = 'f'").limit(1) # <-- MySQL throws an error
puts ua.inspect # <-- Shows me only the attributes of my UserActivity class
puts ua.user.inspect # <-- Can't access anything
So my question is why I can not use "ua.user"?
ua = UserActivity.includes(:user).where("users.gender = 'f'").limit(1) # <-- MySQL throws an error
Includes loads the association data in a separate query, you should use eager_load instead, or you can use
UserActivity.includes(:users).references(:users).where("users.gender = 'f'").limit(1)
Try this
ua = UserActivity.eager_load(:user).where({users: {gender: 'female'}}).limit(1)
Try This One
#user_activities = UserActivity.joins(:user).where("users.gender = female"})
Here #user_activities Gives You Array
#user_activities.each do |user_activity|
user_activity.foo # It Will Give Their Class Attributes Values
user_activity.try(:user).try(:gender) # It Will Give User Class Attributes Values
end
How do i re-write this SQL query in Active Record query in rails
sqlQuery = "SELECT real_estate_agent_assignment_statuses.assignment_status,
COUNT(developer_referrals.id) AS rea_count FROM
real_estate_agent_assignment_statuses LEFT OUTER JOIN developer_referrals ON
developer_referrals.real_estate_agent_assignment_status_id =
real_estate_agent_assignment_statuses.id AND developer_referrals.project_id =
1 AND developer_referrals.developer_staff_id IN (266) WHERE
real_estate_agent_assignment_statuses.assignment_status IN ('Pending
Eligibility Check', 'Completed Eligibility Check') AND
real_estate_agent_assignment_statuses.deleted_at IS NULL GROUP BY
real_estate_agent_assignment_statuses.assignment_status,
real_estate_agent_assignment_statuses.rank ORDER BY
real_estate_agent_assignment_statuses.rank ASC"
My SQL is rusty but I think this will get you started.
sql_query1 = RealEstateAgentAssignmentStatus.joins(:developer_referrals)
.where(:assignment_status => ['Pending Eligibility Check', 'Completed Eligibility Check'])
.group(:assignment_status)
.order(:rank)
.all
http://apidock.com/rails/ActiveRecord/QueryMethods
With the right scopes and relationships set up you can do almost anything.
First step is to define the relationships and scopes. I'm only guessing what your relationships are, but even if totally different the below code should roughly show how it all works:
class DeveloperReferral < ActiveRecord::Base
belongs_to :project
belongs_to :staff_member
scope :with_project, ->(project) {
# merging allows you to define conditions on the join
joins(:project).merge( Project.where(id: project) )
}
scope :with_staff_member, ->(staff_member) {
# merging allows you to define conditions on the join
joins(:staff_member).merge( StaffMember.where(id: staff_member) )
}
end
class RealEstateAgentAssignmentStatus < ActiveRecord::Base
has_many :developer_referrals
scope :with_status, ->(status) { where(status: status) }
scope :not_deleted, -> { where(deleted_at: nil) }
scope :with_project, ->(project) {
# merging allows you to define conditions on the join
joins(:developer_referrals).merge(
DeveloperReferral.with_project(project)
)
}
scope :with_staff_member, ->(staff_member) {
# merging allows you to define conditions on the join
joins(:developer_referrals).merge(
DeveloperReferral.with_staff_member(staff_member)
)
}
end
Then you can build up your query using the scopes.
project = Project.find(1)
staff_members = project.staff_members
statuses =
RealEstateAgentAssignmentStatus
.with_project(project)
.with_staff_member(staff_members)
.with_status([
'Pending Eligibility Check',
'Completed Eligibility Check',
])
.not_deleted
.order(
# we use `arel_table` so that SQL uses the namespaced column name
RealEstateAgentAssignmentStatus.arel_table(:rank),
)
And then you can do your group/count:
status_counts =
statuses
.group(
# we use `arel_table` so that SQL uses the namespaced column name
RealEstateAgentAssignmentStatus.arel_table(:assignment_status),
RealEstateAgentAssignmentStatus.arel_table(:rank),
)
.count(:id)