Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Convert model name to foreign key in queries #7273

Merged
merged 1 commit into from

8 participants

@beerlington

This allows you to specify the model in a belongs_to relationship instead of the foreign key when querying. It does this by looking up the correct foreign key on the association, and changing the column to use that key when appropriate. It came out of a discussion in issue #1736 that seemed to have some interest.

The following queries are now equivalent:

Post.where(:author_id => Author.first)
Post.where(:author => Author.first)

I also think it makes it more consistent with has_many queries where the relationship and query key are both plural:

posts = Post.containing_the_letter_a.limit(5)
Author.where(:posts => posts)
@rafaelfranca
Owner

Thank you for the pull request.

I don't know if is a good idea add more complexity changing a well know API. What are the benefits of this new format?

cc/ @jonleighton @tenderlove

@beerlington

@rafaelfranca maybe it's not worth the added complexity, but I do feel like it's more consistent with other ActiveRecord APIs. When I'm working with models, I tend to be thinking in terms of relationships and objects as opposed to database columns. Take the following two examples:

# associations use models and hide foreign key details
belongs_to :author

# creating related objects uses models too
Post.new(:author => Author.first)

But then when you are querying, you sort of shift to an object/database hybrid mindset:

Post.where(:author_id => Author.first)

I am specifying the foreign key on one side, and the object on the other, and I think it can be hard to remember when you can use the associations and when you can't. Obviously a more practical approach would be to use Author.first.posts, but this gives flexibility in cases where you might not have the relationships fully setup.

Another useful thing about it is that it works with polymorphic belongs_to relationships as well:

class Image < ActiveRecord::Base
  belongs_to :imageable, :polymorphic => true
end

class Cat < ActiveRecord::Base
  has_many :images, :as => :imageable, :dependent => :destroy
end

# So you can do...
Image.where(:imageable => Cat.first)

Honestly though, I was just looking for an excuse to dig into the internals of ActiveRecord and it turned out to be much less hacky than I thought it would. I won't be offended if you guys give it a :-1: since it was a great learning experience. :)

@rafaelfranca
Owner

I'm not giving :-1:.

Make sense this consistence part and this polymorphic case seems a good benefit. I''ll wait for more feedback.

Also, thank you for this great explanation.

@guilleiguaran

I really like this feature :+1: ( haven't checked the implementation yet), can you send a email to Rails-core mailing list to get more feedback?

@Antiarchitect

:+1: Looks naturally.

@al2o3cr

+1 for the idea, especially since I've been writing a lot of conditions against polymorphic belongs_to lately.

However, the current implementation doesn't generate a condition against the polymorphic type column (unless I've completely missed something). In your Imagable example, it generates a condition for imageable_id but not imageable_type.

@beerlington

@al2o3cr good point about not adding the imageable type. I didn't really set out to do anything with polymorphic relationships, it was sort of a side effect. It probably wouldn't be hard to patch it to grab the type too, but maybe outside the scope of this PR (or not?)

@al2o3cr

Personally, the polymorphic case is a huge win for this, since it avoids manually extracting the record's base class name all over the place. The corresponding column is available from the reflection as foreign_type.

@beerlington

@al2o3cr agreed. I'll explore that this evening when I have some free time and see what it would take.

@dhh
Owner
dhh commented

I tentatively like this, but only if it also does the polymorphic conversion. Otherwise its just a setup for inconsistency.

@jonleighton
Collaborator

I like this too, but as other have said, please have a go at the polymorphic case. It need a changelog entry and doc updates also.

@beerlington

@dhh and @jonleighton thanks for the feedback! I did some work on building in polymorphic support but wasn't sure exactly what use cases you were looking for.

There's a shallow query that's pretty trivial:

PriceEstimate.where(:estimate_of_type => 'Treasure', :estimate_of_id => treasure)
PriceEstimate.where(:estimate_of => treasure)

But then things start getting more complicated when you look at a nested query like:

Treasure.where(:price_estimates => {:estimate_of_type => 'Treasure', :estimate_of_id => treasure}).joins(:price_estimates)
Treasure.where(:price_estimates => {:estimate_of => treasure}).joins(:price_estimates)

I have both cases working, but the second is more complicated since it has to look at the polymorphic model's reflection and doesn't work on has_one associations. If the second use case is not something you envision people using, then it may not be worth the added complexity.

I pushed up my changes, but wanted to make sure I had all the test cases covered before updating the docs & changelog and rebasing.

@beerlington

@jonleighton I went ahead and updated the docs & changelog, and squashed commits. Please take a look when you get a chance, thanks!

...rd/lib/active_record/relation/predicate_reflection.rb
((5 lines not shown))
+ # Find the foreign key when using queries such as:
+ # Post.where(:author => author)
+ #
+ # For polymorphic relationships, find the foreign key and type:
+ # PriceEstimate.where(:estimate_of => treasure)
+ class PredicateReflection
+ attr_reader :foreign_key, :foreign_type
+
+ def self.find(parent_column, model, value)
+ # value must be an ActiveRecord object
+ return nil unless value.class < Model::Tag
+
+ if reflection = model.reflections[parent_column.to_sym]
+ if reflection.options[:polymorphic]
+ new(reflection.foreign_key, reflection.foreign_type)
+ elsif reflection.options[:as]
@jonleighton Collaborator

Probably we should make this work more generally? E.g. I might want to do Post.joins(:comments).where(comments: { author: david }). So I think there needs to be one level of recursion here.

@jonleighton It doesn't appear that the elsif reflection.options[:as] case is even needed anymore. Maybe this was due to a change in ActiveRecord or ARel in the last month? Regardless, my tests now pass without it so I'm going to remove it. I think the example case you mentioned is already covered by some of the "nested" tests I wrote. See nested_where, polymorphic_nested_where, and sti_polymorphic_nested_where. If that's not what you had in mind, please let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
...rd/lib/active_record/relation/predicate_reflection.rb
@@ -0,0 +1,37 @@
+# Used by ActiveRecord::PredicateBuilder to find foreign keys and foreign types
+# when the query parameter is an ActiveRecord object
+module ActiveRecord
+
+ # Find the foreign key when using queries such as:
+ # Post.where(:author => author)
+ #
+ # For polymorphic relationships, find the foreign key and type:
+ # PriceEstimate.where(:estimate_of => treasure)
+ class PredicateReflection
@jonleighton Collaborator

I don't think there's a good reason to have a class for this; it's just data, no behaviour. And the data is only two values.

I would make your find method just be a method on PredicateBuilder that returns a 2 value array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@jonleighton
Collaborator

@beerlington Sorry for being slow. It's looking good but I added a couple of comments. Could you also update the querying guide? Thanks.

I'll try to be faster to reply this time - please @-mention me so I see you reply. Cheers

@beerlington

@jonleighton I made some updates based on your comments. I wasn't totally sure on your comment about making it work more generally, so please take another look and see if this meets your expectations.

@jonleighton
Collaborator

@beerlington yep, seems that my comment was not necessary - you have a test for the example I gave.

There are a few refactorings I'd like to make but I think it will be easier for me to just merge and perform them after that. Could you squash the commits please? Then I will merge it.

Thanks

activerecord/CHANGELOG.md
@@ -89,6 +89,18 @@
*Wojciech Wnętrzak*
+* Accept belongs_to (including polymorphic) association keys in queries
@guilleiguaran Owner

Please move this to the top of file

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
@beerlington beerlington Accept belongs_to assoc. keys in ActiveRecord queries
Allows you to specify the model association key in a belongs_to
relationship instead of the foreign key.

The following queries are now equivalent:

Post.where(:author_id => Author.first)
Post.where(:author => Author.first)

PriceEstimate.where(:estimate_of_type => 'Treasure', :estimate_of_id => treasure)
PriceEstimate.where(:estimate_of => treasure)
3da275c
@beerlington

@jonleighton @guilleiguaran I moved the changelog entry to the top and squashed the commits.

@jonleighton jonleighton merged commit b5aed34 into from
@henrik

Awesome. Just looked into adding this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Sep 11, 2012
  1. @beerlington

    Accept belongs_to assoc. keys in ActiveRecord queries

    beerlington authored
    Allows you to specify the model association key in a belongs_to
    relationship instead of the foreign key.
    
    The following queries are now equivalent:
    
    Post.where(:author_id => Author.first)
    Post.where(:author => Author.first)
    
    PriceEstimate.where(:estimate_of_type => 'Treasure', :estimate_of_id => treasure)
    PriceEstimate.where(:estimate_of => treasure)
This page is out of date. Refresh to see the latest.
View
12 activerecord/CHANGELOG.md
@@ -1,5 +1,17 @@
## Rails 4.0.0 (unreleased) ##
+* Accept belongs_to (including polymorphic) association keys in queries
+
+ The following queries are now equivalent:
+
+ Post.where(:author => author)
+ Post.where(:author_id => author)
+
+ PriceEstimate.where(:estimate_of => treasure)
+ PriceEstimate.where(:estimate_of_type => 'Treasure', :estimate_of_id => treasure)
+
+ *Peter Brown*
+
* Use native `mysqldump` command instead of `structure_dump` method
when dumping the database structure to a sql file. Fixes #5547.
View
52 activerecord/lib/active_record/relation/predicate_builder.rb
@@ -1,12 +1,25 @@
module ActiveRecord
class PredicateBuilder # :nodoc:
def self.build_from_hash(engine, attributes, default_table)
- attributes.map do |column, value|
+ queries = []
+
+ attributes.each do |column, value|
table = default_table
if value.is_a?(Hash)
table = Arel::Table.new(column, engine)
- value.map { |k,v| build(table[k.to_sym], v) }
+
+ value.each do |k,v|
+ if rk = find_reflection_key(column, v, v)
+ if rk[:foreign_type]
+ queries << build(table[rk[:foreign_type]], v.class.base_class)
+ end
+
+ k = rk[:foreign_key]
+ end
+
+ queries << build(table[k.to_sym], v)
+ end
else
column = column.to_s
@@ -15,9 +28,19 @@ def self.build_from_hash(engine, attributes, default_table)
table = Arel::Table.new(table_name, engine)
end
- build(table[column.to_sym], value)
+ if rk = find_reflection_key(column, engine, value)
+ if rk[:foreign_type]
+ queries << build(table[rk[:foreign_type]], value.class.base_class)
+ end
+
+ column = rk[:foreign_key]
+ end
+
+ queries << build(table[column.to_sym], value)
end
- end.flatten
+ end
+
+ queries
end
def self.references(attributes)
@@ -31,6 +54,27 @@ def self.references(attributes)
end.compact
end
+ # Find the foreign key when using queries such as:
+ # Post.where(:author => author)
+ #
+ # For polymorphic relationships, find the foreign key and type:
+ # PriceEstimate.where(:estimate_of => treasure)
+ def self.find_reflection_key(parent_column, model, value)
+ # value must be an ActiveRecord object
+ return nil unless value.class < Model::Tag
+
+ if reflection = model.reflections[parent_column.to_sym]
+ if reflection.options[:polymorphic]
+ {
+ :foreign_key => reflection.foreign_key,
+ :foreign_type => reflection.foreign_type
+ }
+ else
+ { :foreign_key => reflection.foreign_key }
+ end
+ end
+ end
+
private
def self.build(attribute, value)
case value
View
18 activerecord/lib/active_record/relation/query_methods.rb
@@ -340,6 +340,24 @@ def bind!(value)
# User.where({ created_at: (Time.now.midnight - 1.day)..Time.now.midnight })
# # SELECT * FROM users WHERE (created_at BETWEEN '2012-06-09 07:00:00.000000' AND '2012-06-10 07:00:00.000000')
#
+ # In the case of a belongs_to relationship, an association key can be used
+ # to specify the model if an ActiveRecord object is used as the value.
+ #
+ # author = Author.find(1)
+ #
+ # # The following queries will be equivalent:
+ # Post.where(:author => author)
+ # Post.where(:author_id => author)
+ #
+ # This also works with polymorphic belongs_to relationships:
+ #
+ # treasure = Treasure.create(:name => 'gold coins')
+ # treasure.price_estimates << PriceEstimate.create(:price => 125)
+ #
+ # # The following queries will be equivalent:
+ # PriceEstimate.where(:estimate_of => treasure)
+ # PriceEstimate.where(:estimate_of_type => 'Treasure', :estimate_of_id => treasure)
+ #
# === Joins
#
# If the relation is the result of a join, you may create a condition which uses any of the
View
70 activerecord/test/cases/relation/where_test.rb
@@ -1,9 +1,77 @@
require "cases/helper"
+require 'models/author'
+require 'models/price_estimate'
+require 'models/treasure'
require 'models/post'
module ActiveRecord
class WhereTest < ActiveRecord::TestCase
- fixtures :posts
+ fixtures :posts, :authors
+
+ def test_belongs_to_shallow_where
+ author = Post.first.author
+ query_with_id = Post.where(:author_id => author)
+ query_with_assoc = Post.where(:author => author)
+
+ assert_equal query_with_id.to_sql, query_with_assoc.to_sql
+ end
+
+ def test_belongs_to_nested_where
+ author = Post.first.author
+ query_with_id = Author.where(:posts => {:author_id => author}).joins(:posts)
+ query_with_assoc = Author.where(:posts => {:author => author}).joins(:posts)
+
+ assert_equal query_with_id.to_sql, query_with_assoc.to_sql
+ end
+
+ def test_polymorphic_shallow_where
+ treasure = Treasure.create(:name => 'gold coins')
+ treasure.price_estimates << PriceEstimate.create(:price => 125)
+
+ query_by_column = PriceEstimate.where(:estimate_of_type => 'Treasure', :estimate_of_id => treasure)
+ query_by_model = PriceEstimate.where(:estimate_of => treasure)
+
+ assert_equal query_by_column.to_sql, query_by_model.to_sql
+ end
+
+ def test_polymorphic_sti_shallow_where
+ treasure = HiddenTreasure.create!(:name => 'gold coins')
+ treasure.price_estimates << PriceEstimate.create!(:price => 125)
+
+ query_by_column = PriceEstimate.where(:estimate_of_type => 'Treasure', :estimate_of_id => treasure)
+ query_by_model = PriceEstimate.where(:estimate_of => treasure)
+
+ assert_equal query_by_column.to_sql, query_by_model.to_sql
+ end
+
+ def test_polymorphic_nested_where
+ estimate = PriceEstimate.create :price => 125
+ treasure = Treasure.create :name => 'Booty'
+
+ treasure.price_estimates << estimate
+
+ query_by_column = Treasure.where(:price_estimates => {:estimate_of_type => 'Treasure', :estimate_of_id => treasure}).joins(:price_estimates)
+ query_by_model = Treasure.where(:price_estimates => {:estimate_of => treasure}).joins(:price_estimates)
+
+ assert_equal treasure, query_by_column.first
+ assert_equal treasure, query_by_model.first
+ assert_equal query_by_column.to_a, query_by_model.to_a
+ end
+
+ def test_polymorphic_sti_nested_where
+ estimate = PriceEstimate.create :price => 125
+ treasure = HiddenTreasure.create!(:name => 'gold coins')
+ treasure.price_estimates << PriceEstimate.create!(:price => 125)
+
+ treasure.price_estimates << estimate
+
+ query_by_column = Treasure.where(:price_estimates => {:estimate_of_type => 'Treasure', :estimate_of_id => treasure}).joins(:price_estimates)
+ query_by_model = Treasure.where(:price_estimates => {:estimate_of => treasure}).joins(:price_estimates)
+
+ assert_equal treasure, query_by_column.first
+ assert_equal treasure, query_by_model.first
+ assert_equal query_by_column.to_a, query_by_model.to_a
+ end
def test_where_error
assert_raises(ActiveRecord::StatementInvalid) do
View
3  activerecord/test/models/treasure.rb
@@ -6,3 +6,6 @@ class Treasure < ActiveRecord::Base
accepts_nested_attributes_for :looter
end
+
+class HiddenTreasure < Treasure
+end
View
1  activerecord/test/schema/schema.rb
@@ -693,6 +693,7 @@ def create_table(*args, &block)
create_table :treasures, :force => true do |t|
t.column :name, :string
+ t.column :type, :string
t.column :looter_id, :integer
t.column :looter_type, :string
end
View
7 guides/source/active_record_querying.textile
@@ -465,6 +465,13 @@ The field name can also be a string:
Client.where('locked' => true)
</ruby>
+In the case of a belongs_to relationship, an association key can be used to specify the model if an ActiveRecord object is used as the value. This method works with polymorphic relationships as well.
+
+<ruby>
+Post.where(:author => author)
+Author.joins(:posts).where(:posts => {:author => author})
+</ruby>
+
NOTE: The values cannot be symbols. For example, you cannot do +Client.where(:status => :active)+.
h5(#hash-range_conditions). Range Conditions
Something went wrong with that request. Please try again.