This project is archived and is in readonly mode.
Rails 3.0.3 Eager Loading associations with additional conditions causes the ON statement to be improperly constructed
Reported by Orion Delwaterman | December 22nd, 2010 @ 10:01 PM
Summary
When an association with conditions is chained together with
additional conditions using Relations, ActiveRecord and Arel shifts
the association's conditions from the WHERE
clause to
the ON
clause. But in doing so it drops the foreign
key relationship and improperly quotes conditions. Quick
example:
Post.includes(:special_tags).where(:user_id => 10).all
will result in the following SQL (using sqlite3):
SELECT "posts"."id" as t0_r0, "posts"."title" AS t0_r0, "user_id" AS t0_r1, "tags"."id" AS t1_r0, "tags"."post_id" AS t1_r1, "tags"."name" AS t1_r2, "tags"."context_type" AS t1_r3
FROM "posts"
LEFT OUTER JOIN "taggings" ON "posts"."id" = "taggings"."post_id"
LEFT OUTER JOIN "tags" ON "tags"."name" = ''special''
WHERE "posts"."id" = 10
There are two issues with the second LEFT OUTER
JOIN
statement. First its missing the foreign key
relationship condition "taggings"."tag_id" =
"tags"."id"
. Second it's improperly double quoting (two
single quotes on each side) the word "special" with
"tags"."name" = ''special''
Recreating the issue
This is a modified version of our code at work where we originally discovered the issue.
Schema:
create_table "recipes", :force => true do |t|
t.string "name"
end
create_table "taggings", :force => true do |t|
t.integer "taggable_id"
t.string "taggable_type"
t.integer "position"
t.integer "tag_id"
t.datetime "created_at"
t.datetime "updated_at"
end
create_table "tags", :force => true do |t|
t.string "name"
t.string "context_type"
t.datetime "created_at"
t.datetime "updated_at"
end
models:
class Recipe < ActiveRecord::Base
has_many :taggings, :as => :taggable, :dependent => :destroy
has_many :courses, :through => :taggings, :source => :tag, :conditions => {:tags => {:context_type => 'course'}}
scope :ordered_by_course, includes(:courses).where(:taggings => {:position => 0}).order("UPPER(tags.name) ASC").order("UPPER(recipes.name) ASC")
end
class Tagging < ActiveRecord::Base
belongs_to :taggable, :polymorphic => true
belongs_to :tag
before_create do
self.position = self.taggable.send(self.tag.context_type.to_s.pluralize).count + 1
end
end
class Tag < ActiveRecord::Base
has_many :taggings, :dependent => :destroy
class << self
["course", "main_ingredient", "cuisine", "occasion", "convenience", "cooking_method"].each do |context|
define_method context.pluralize do
where("tags.context_type = '#{context}'")
end
end
end
end
Create some dummy data with IRB:
recipe = Recipe.create!(:name => "foo")
tag = Tag.create!(:name => "Main Dish", :context_type => "course")
recipe.courses << tag
Now using IRB and do a normal load of the association:
recipe = Recipe.first
recipe.courses # => [#<Tag id: 1, name: "Main Dish", context_type: "course", created_at: "2010-11-19 17:04:14", updated_at: "2010-11-19 17:04:14">]
SQL in the log:
SELECT "tags".* FROM "tags" INNER JOIN "taggings" ON "tags".id = "taggings".tag_id WHERE (("taggings".taggable_id = 1) AND ("taggings".taggable_type = 'Recipe') AND (("tags"."context_type" = 'course')))
Back to IRB now lets try the scope:
recipes = Recipe.ordered_by_course
recipes.first.courses # => []
That's wrong check the SQL in the log:
SELECT "recipes"."id" AS t0_r0, "recipes"."name" AS t0_r1,"tags"."id" AS t1_r0, "tags"."name" AS t1_r1, "tags"."context_type" AS t1_r2, "tags"."created_at" AS t1_r3, "tags"."updated_at" AS t1_r4
FROM "recipes"
LEFT OUTER JOIN "taggings" ON "recipes"."id" = "taggings"."taggable_id" AND "taggings"."taggable_type" = 'Recipe'
LEFT OUTER JOIN "tags" ON '"tags"."context_type" = ''course''
WHERE (recipes.deleted_at IS NULL) AND ("taggings"."position" = 1)
ORDER BY UPPER(tags.name) ASC, UPPER(recipes.name) ASC
Wow this is a bit of an issue.
The Two Nasty Issues
Dirty Nasty Issue 1 - Where did the foreign keys go?
The first problem is within ActiveRecord::QueryMethods#build_joins and ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation#association_join. ActiveRecord::QueryMethods#build_joins is responsible for building the joins for the query (lib/active_record/relation/query_methods.rb:215). Check out this snippet here at line 235-243
to_join = []
join_dependency.join_associations.each do |association|
if (association_relation = association.relation).is_a?(Array)
to_join << [association_relation.first, association.join_type, association.association_join.first]
to_join << [association_relation.last, association.join_type, association.association_join.last]
else
to_join << [association_relation, association.join_type, association.association_join]
end
end
Essentially if the association relation is an array (array of
ARel Nodes) we are dealing some type of has_many
:through
relationship. And this snippet assumes that there
are only two items in the array (the first should be all the joins
for the recipes to taggings and the secound should be for the
taggings to tags).
But now take a look at
ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation#association_join
(in the rails 3-0-stable branch that would be
activerecord/lib/active_record/associations.rb:2135). The first
part of the function (lines 2136-2207) will set @join to be an
array of two length:
def association_join
return @join if @join
aliased_table = Arel::Table.new(table_name, :as => @aliased_table_name,
:engine => arel_engine,
:columns => klass.columns)
parent_table = Arel::Table.new(parent.table_name, :as => parent.aliased_table_name,
:engine => arel_engine,
:columns => parent.active_record.columns)
@join = case reflection.macro
when :has_and_belongs_to_many
join_table = Arel::Table.new(options[:join_table], :as => aliased_join_table_name, :engine => arel_engine)
fk = options[:foreign_key] || reflection.active_record.to_s.foreign_key
klass_fk = options[:association_foreign_key] || klass.to_s.foreign_key
[
join_table[fk].eq(parent_table[reflection.active_record.primary_key]),
aliased_table[klass.primary_key].eq(join_table[klass_fk])
]
when :has_many, :has_one
if reflection.options[:through]
join_table = Arel::Table.new(through_reflection.klass.table_name, :as => aliased_join_table_name, :engine => arel_engine)
jt_foreign_key = jt_as_extra = jt_source_extra = jt_sti_extra = nil
first_key = second_key = as_extra = nil
if through_reflection.options[:as] # has_many :through against a polymorphic join
jt_foreign_key = through_reflection.options[:as].to_s + '_id'
jt_as_extra = join_table[through_reflection.options[:as].to_s + '_type'].eq(parent.active_record.base_class.name)
else
jt_foreign_key = through_reflection.primary_key_name
end
case source_reflection.macro
when :has_many
if source_reflection.options[:as]
first_key = "#{source_reflection.options[:as]}_id"
second_key = options[:foreign_key] || primary_key
as_extra = aliased_table["#{source_reflection.options[:as]}_type"].eq(source_reflection.active_record.base_class.name)
else
first_key = through_reflection.klass.base_class.to_s.foreign_key
second_key = options[:foreign_key] || primary_key
end
unless through_reflection.klass.descends_from_active_record?
jt_sti_extra = join_table[through_reflection.active_record.inheritance_column].eq(through_reflection.klass.sti_name)
end
when :belongs_to
first_key = primary_key
if reflection.options[:source_type]
second_key = source_reflection.association_foreign_key
jt_source_extra = join_table[reflection.source_reflection.options[:foreign_type]].eq(reflection.options[:source_type])
else
second_key = source_reflection.primary_key_name
end
end
[
[parent_table[parent.primary_key].eq(join_table[jt_foreign_key]), jt_as_extra, jt_source_extra, jt_sti_extra].reject{|x| x.blank? },
aliased_table[first_key].eq(join_table[second_key])
]
elsif reflection.options[:as]
id_rel = aliased_table["#{reflection.options[:as]}_id"].eq(parent_table[parent.primary_key])
type_rel = aliased_table["#{reflection.options[:as]}_type"].eq(parent.active_record.base_class.name)
[id_rel, type_rel]
else
foreign_key = options[:foreign_key] || reflection.active_record.name.foreign_key
[aliased_table[foreign_key].eq(parent_table[reflection.options[:primary_key] || parent.primary_key])]
end
when :belongs_to
[aliased_table[options[:primary_key] || reflection.klass.primary_key].eq(parent_table[options[:foreign_key] || reflection.primary_key_name])]
end
Awesome. But the rest of the function now breaks the assumption that its only going to return that it will return an array of length 2 (lines 2209-2225):
unless klass.descends_from_active_record?
sti_column = aliased_table[klass.inheritance_column]
sti_condition = sti_column.eq(klass.sti_name)
klass.descendants.each {|subclass| sti_condition = sti_condition.or(sti_column.eq(subclass.sti_name)) }
@join << sti_condition
end
[through_reflection, reflection].each do |ref|
if ref && ref.options[:conditions]
@join << interpolate_sql(sanitize_sql(ref.options[:conditions], aliased_table_name))
end
end
@join
end
If the reflection has conditions or if the underlying klass is
an STI you now have 3 or more items in your array. In the example I
am using we would have three:
1. the Arel AST nodes for the recipes to taggings table
2. the Arel AST nodes for the foreign key of taggings to tags
3. the condition clauses checking the context type of the
tag.
Now look back at ActiveRecord::QueryMethods#build_joins (lib/active_record/relation/query_methods.rb:235-243).
to_join = []
join_dependency.join_associations.each do |association|
if (association_relation = association.relation).is_a?(Array)
to_join << [association_relation.first, association.join_type, association.association_join.first]
to_join << [association_relation.last, association.join_type, association.association_join.last]
else
to_join << [association_relation, association.join_type, association.association_join]
end
end
Yup its totally going to drop the middle condition. So we need to patch ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation#association_join to only return an array of length two (since its grouping the joins for each relationship in an array). I'm just not sure what else this is going to break.
Dirty Nasty Issue 2 - What's with the double single quotes?
Recall that the generated SQL had this little snippet LEFT
OUTER JOIN "tags" ON '"tags"."context_type" = ''course''
.
Why is it doing this? Well the problem turns out to be
ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation#association_join
again. Re-examine this snippet
(activerecord/lib/active_record/associations.rb:218-223)
[through_reflection, reflection].each do |ref|
if ref && ref.options[:conditions]
@join << interpolate_sql(sanitize_sql(ref.options[:conditions], aliased_table_name))
end
end
The #sanitize_sql (which is delegated to the active_record) returns a sql string. That is its not passing ARel AST nodes but actual SQL. That might not seem like much of an issue until you realize that ARel will treat this a string value, NOT a sql expression. We have to look at the ARel code to see what happens (I am using the 2.0.6 gem as reference). Let's look at how ARel's ToSql visitor handles the ON node (lib/arel/visitors/to_sql.rb:190-192):
def visit_Arel_Nodes_On o
"ON #{visit o.expr}"
end
Now remember that
ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation#association_join
set one the condition here to be a string. That means o.expr
# => "\"tags\".\"content_type\" = 'course'". Well look what
happens next (lib/arel/visitors/to_sql.rb:278):
def visit_String o; quote(o, @last_column) end
It's going to quote the string as if its a String value to be passed into the database, not the actual sql to be run (lib/arel/visitors/to_sql.rb:294-296)
def quote value, column = nil
@connection.quote value, column
end
And sure enough when we trace this down in activerecord we find (lib/active_record/connection_adapters/abstract/quoting.rb:40-42)
def quote_string(s)
s.gsub(/\\/, '\&\&').gsub(/'/, "''") # ' (for ruby-mode)
end
Wow. At least we know where the issue is. The solution seems to be to patch ActiveRecord::Associations::ClassMethods::JoinDependency::JoinAssociation#association_join so that it does not just give a string, but actual ARel ast nodes. But its going to require some work.
Patches
I am currently working on the patches for this issue, but it may take me a while to test. Not to mention that I currently do not have MySQL or PostgreSQL installed on my mac and I've already spent way too much time tracking this down. But I will give it my best shot and hopefully whoever can check as needed. I'll post patches as soon as I have them. I am working off the Rails 3-0-stable branch.
Side Note
I checked out 'master' of activerecord, and its gone through a major rewrite. I am not sure if the rewrite addressed this issue, but once I complete my patches for the 3.0 line I can port the tests over to double check
Comments and changes to this ticket
-
rails March 23rd, 2011 @ 12:00 AM
- State changed from new to open
This issue has been automatically marked as stale because it has not been commented on for at least three months.
The resources of the Rails core team are limited, and so we are asking for your help. If you can still reproduce this error on the 3-0-stable branch or on master, please reply with all of the information you have about it and add "[state:open]" to your comment. This will reopen the ticket for review. Likewise, if you feel that this is a very important feature for Rails to include, please reply with your explanation so we can consider it.
Thank you for all your contributions, and we hope you will understand this step to focus our efforts where they are most helpful.
-
rails March 23rd, 2011 @ 12:00 AM
- State changed from open to stale
-
Jarrett Irons March 23rd, 2011 @ 06:28 AM
- State changed from stale to open
I am having the same issue where i have a model that has a has_many association. When using 'includes' and 'where' when looking for a column through another table it seems as though the foreign_key is nil.
ruby-1.9.2-p136 :001 > User.includes('products').where("products.name LIKE ?", "%futura-profileidentity%" ).all NoMethodError: undefined method `eq' for nil:NilClass from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activesupport-3.0.5/lib/active_support/whiny_nil.rb:48:in `method_missing' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/associations.rb:2208:in `association_join' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation/query_methods.rb:260:in `block in build_joins' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation/query_methods.rb:255:in `each' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation/query_methods.rb:255:in `build_joins' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation/query_methods.rb:177:in `build_arel' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation/query_methods.rb:150:in `arel' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation.rb:64:in `to_a' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation/finder_methods.rb:189:in `find_with_associations' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation.rb:64:in `to_a' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/activerecord-3.0.5/lib/active_record/relation/finder_methods.rb:143:in `all' from (irb):1 from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/railties-3.0.5/lib/rails/commands/console.rb:44:in `start' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/railties-3.0.5/lib/rails/commands/console.rb:8:in `start' from /Users/cyberpunk/.rvm/gems/ruby-1.9.2-p136/gems/railties-3.0.5/lib/rails/commands.rb:23:in `<top (required)>' from script/rails:6:in `require' from script/rails:6:in `<main>'ruby-1.9.2-p136 :002 >
This is happening in ruby 1.8.7, 1.9.2 and rails 3.0.3, 3.0.5 in all combinations.
Thanks,
Jarrett[state:open]
Create your profile
Help contribute to this project by taking a few moments to create your personal profile. Create your profile »
<h2 style="font-size: 14px">Tickets have moved to Github</h2>
The new ticket tracker is available at <a href="https://github.com/rails/rails/issues">https://github.com/rails/rails/issues</a>