Get dynamic table alias in Rails scope - ruby-on-rails

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.

Related

Unique join table reference inside model scope

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.

How do I query with '.includes' across more than two tables?

I have the following associations:
class Captain
has_many :boats
end
class Boat
belongs_to :captain
has_many :classifications
end
class Classification
has_many :boats
end
I want to find out which captains have boats that have classifications with :name attributes of "catamaran."
This has been my best guess so far:
Captain.includes(:boats, :classifications).where(:boats => {:classifications => {:name => "catamaran"}})
Try this
Captain.joins(boats: :classifications).where(classifications: { name: "catamaran" })
This query results in following SQL query
SELECT * FROM `captains`
INNER JOIN `boats` ON `boats`.`captain_id` = `captains`.`id`
INNER JOIN `join_table` ON `join_table`.`boat_id` = `boat`.`id`
INNER JOIN `classifications` ON `join_table`.`classification_id` = `classifications`.id
#Sujan Adiga has right!
If you use the include method, active record generate 2 separates sql query. The first for your main Model, and the second for your inclued model. But you don't have access on the included model in your first query.
When you use the joins method, active record generate sql query with joins statement. So you can use the joined model in your clause where.

Rails active record scope based on count of has_many scope

I have Order model and a Container model with a scope as below:
class Order < ActiveRecord::Base
has_many :containers, inverse_of: :order, dependent: :destroy
end
class Container < ActiveRecord::Base
scope :full_pickup_ready, -> { where.not(full_pickup_ready_date: nil) }
end
The order model has a field call quantity which represents the quantity of containers the order requires but is not necessarily the size of the containers association as not all container data is entered at the time of order creation.
I would like to have a scope on the Order model based on whether the count of the containers with a full_pickup_ready_date is less than the quantity field of the order.
I know I can use merge on the order model to access the scope on the containers like this:
def self.at_origin
joins(:containers).merge(Container.full_pickup_ready).uniq
end
but how can I limit the scope to orders where the total number of containers with a full_pickup_ready_date is less that the quantity field on the order?
UPDATE:
this is reasonably close, but I don't think using the select is efficient:
includes(:containers).select {|o| o.containers.full_pickup_ready.size < o.quantity }
If you're willing to forgo reuse of the scope on Container, then you should be able to use something like:
# scope on Order
joins(:containers)
.group("orders.id")
.having("count(CASE WHEN full_pickup_ready_date THEN 1 END) < orders.quantity")
I think you need to get this SQL query to work for you. So the idea is to get the SQL query right, and then translate it to "Rails".
SQL
If I am correct, this should be the SQL query you want to achieve. Maybe you can try it in your rails db
SELECT orders.*
FROM orders JOIN containers
WHERE containers.id = orders.id
AND (
SELECT COUNT(containers.id)
FROM containers
WHERE containers.full_pickup_ready_date IS NOT NULL
) < orders.quantity;
ActiveRecord
If this is the right query, then we can do this using rails
Order.joins(:containers).where("( SELECT COUNT(containers.id) FROM containers WHERE containers.full_pickup_ready_date IS NOT NULL ) < orders.quantity")
This should return an ActiveRecord relation. You could also do this:
sql = %{
SELECT orders.*
FROM orders JOIN containers
WHERE containers.id = orders.id
AND (
SELECT COUNT(containers.id)
FROM containers
WHERE containers.full_pickup_ready_date IS NOT NULL
) < orders.quantity;
}.gsub(/\s+/, " ").strip
Order.find_by_sql(sql)
Just add this in a class method (better than scope IMHO) and you are good to go.
So, your Order class should look like this:
class Order < ActiveRecord::Base
has_many :containers, inverse_of: :order, dependent: :destroy
def self.gimme_a_query_name
joins(:containers).where("( SELECT COUNT(containers.id) FROM containers WHERE containers.full_pickup_ready_date IS NOT NULL ) < orders.quantity")
end
def self.gimme_another_query_name
sql = %{
SELECT orders.*
FROM orders JOIN containers
WHERE containers.id = orders.id
AND (
SELECT COUNT(containers.id)
FROM containers
WHERE containers.full_pickup_ready_date IS NOT NULL
) < orders.quantity;
}.gsub(/\s+/, " ").strip
find_by_sql(sql)
end
end
I have no way to try this, but it should work with few tweak to get the SQL query right.
I hope this help!

Rails ActiveRecord query using multiple joins involving polymorphic association

I'm trying to figure out how I can replicate the following SQL query using AR given the model definitions below. The cast is necessary to perform the average. The result set should group foo by bar (which comes from the polymorphic association). Any help is appreciated.
SQL:
SELECT AVG(CAST(r.foo AS decimal)) "Average", s.bar
FROM rotation r INNER JOIN cogs c ON r.cog_id = c.id
INNER JOIN sprockets s ON s.id = c.crankable_id
INNER JOIN machinists m ON r.machinist_id = m.id
WHERE c.crankable_type = 'Sprocket' AND
r.machine_id = 123 AND
m.shop_id = 1
GROUP BY s.bar
ActiveRecord Models:
class Rotation < ActiveRecord::Base
belongs_to :cog
belongs_to :machinist
belongs_to :machine
end
class Cog < ActiveRecord::Base
belongs_to :crankable, :polymorphic => true
has_many :rotation
end
class Sprocket < ActiveRecord::Base
has_many :cogs, :as => :crankable
end
class Machinist < ActiveRecord::Base
belongs_to :shop
end
UPDATE
I've figured out a way to make it work, but it feels like cheating. Is there are a better way than this?
Sprocket.joins('INNER JOIN cogs c ON c.crankable_id = sprockets.id',
'INNER JOIN rotations r ON r.cog_id = c.id',
'INNER JOIN machinists m ON r.machinist_id = m.id')
.select('sprockets.bar', 'r.foo')
.where(:r => {:machine_id => 123}, :m => {:shop_id => 1})
.group('sprockets.bar')
.average('CAST(r.foo AS decimal)')
SOLUTION
Albin's answer didn't work as-is, but did lead me to a working solution. First, I had a typo in Cog and had to change the relation from:
has_many :rotation
to the plural form:
has_many :rotations
With that in place, I am able to use the following query
Sprocket.joins(cogs: {rotations: :machinist})
.where({ machinists: { shop_id: 1 }, rotations: { machine_id: 123}})
.group(:bar)
.average('CAST(rotations.foo AS decimal)')
The only real difference is that I had to separate the where clause since a machine does not belong to a machinist. Thanks Albin!
I think this code is a little simpler and taking more help from AR
Sprocket
.joins(cogs: {rotations: :machinist})
.where({ machinists: { machine_id: 123, shop_id: 1 } } )
.group(:bar)
.average('CAST(rotations.foo AS decimal)')
The select clause was unnecessary, you don't have to select values since you only need them internally in the query, AR helps you decide what you need afterwards.
I tested this out using a similar structure in one of my own projects but it is not the exact same models so there might be a typo or something in there if it does not run straight up. I ran:
Activity
.joins(locations: {participants: :stuff})
.where({ stuffs: { my_field: 1 } })
.group(:title)
.average('CAST(participants.date_of_birth as decimal)')
producing this query
SELECT AVG(CAST(participants.date_of_birth as decimal)) AS average_cast_participants_date_of_birth_as_decimal, title AS title
FROM `activities`
INNER JOIN `locations` ON `locations`.`activity_id` = `activities`.`id`
INNER JOIN `participants` ON `participants`.`location_id` = `locations`.`id`
INNER JOIN `stuffs` ON `stuffs`.`id` = `participants`.`stuff_id`
WHERE `stuffs`.`my_field` = 1
GROUP BY title
which AR makes in to a hash looking like this:
{"dummy title"=>#<BigDecimal:7fe9fe44d3c0,'0.19652273E4',18(18)>, "stats test"=>nil}

ActiveRecord find categories which contain at least one item

Support I have two models for items and categories, in a many-to-many relation
class Item < ActiveRecord::Base
has_and_belongs_to_many :categories
class Category < ActiveRecord::Base
has_and_belongs_to_many :items
Now I want to filter out categories which contain at least one items, what will be the best way to do this?
I would like to echo #Delba's answer and expand on it because it's correct - what #huan son is suggesting with the count column is completely unnecessary, if you have your indexes set up correctly.
I would add that you probably want to use .uniq, as it's a many-to-many you only want DISTINCT categories to come back:
Category.joins(:items).uniq
Using the joins query will let you more easily work conditions into your count of items too, giving much more flexibility. For example you might not want to count items where enabled = false:
Category.joins(:items).where(:items => { :enabled => true }).uniq
This would generate the following SQL, using inner joins which are EXTREMELY fast:
SELECT `categories`.* FROM `categories` INNER JOIN `categories_items` ON `categories_items`.`category_id` = `categories`.`id` INNER JOIN `items` ON `items`.`id` = `categories_items`.`item_id` WHERE `items`.`enabled` = 1
Good luck,
Stu
Category.joins(:items)
More details here: http://guides.rubyonrails.org/active_record_querying.html#joining-tables
please notice, what the other guys answererd is NOT performant!
the most performant solution:
better to work with a counter_cache and save the items_count in the model!
scope :with_items, where("items_count > 0")
has_and_belongs_to_many :categories, :after_add=>:update_count, :after_remove=>:update_count
def update_count(category)
category.items_count = category.items.count
category.save
end
for normal "belongs_to" relation you just write
belongs_to :parent, :counter_cache=>true
and in the parent_model you have an field items_count (items is the pluralized has_many class name)
http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html
in a has_and_belongs_to_many relation you have to write it as your own as above
scope :has_item, where("#{table_name}.id IN (SELECT categories_items.category_id FROM categories_items")
This will return all categories which have an entry in the join table because, ostensibly, a category shouldn't have an entry there if it does not have an item. You could add a AND categories_items.item_id IS NOT NULL to the subselect condition just to be sure.
In case you're not aware, table_name is a method which returns the table name of ActiveRecord class calling it. In this case it would be "categories".

Resources