Skip to content

Commit

Permalink
Introduce dynamic scopes for ActiveRecord: you can now use class meth…
Browse files Browse the repository at this point in the history
…ods like scoped_by_user_name(user_name) and scoped_by_user_name_and_password(user_name, password) that will use the scoped method with attributes you supply. [#1648 state:committed]

Signed-off-by: David Heinemeier Hansson <david@loudthinking.com>
  • Loading branch information
yaroslav authored and dhh committed Dec 28, 2008
1 parent 1fb2755 commit 66ee589
Show file tree
Hide file tree
Showing 5 changed files with 72 additions and 1 deletion.
2 changes: 2 additions & 0 deletions activerecord/CHANGELOG
@@ -1,5 +1,7 @@
*2.3.0/3.0*

* Added dynamic scopes ala dynamic finders #1648 [Yaroslav Markin]

* Fixed that ActiveRecord::Base#new_record? should return false (not nil) for existing records #1219 [Yaroslav Markin]

* I18n the word separator for error messages. Introduces the activerecord.errors.format.separator translation key. #1294 [Akira Matsuda]
Expand Down
1 change: 1 addition & 0 deletions activerecord/lib/active_record.rb
Expand Up @@ -51,6 +51,7 @@ def self.load_all!
autoload :Callbacks, 'active_record/callbacks'
autoload :Dirty, 'active_record/dirty'
autoload :DynamicFinderMatch, 'active_record/dynamic_finder_match'
autoload :DynamicScopeMatch, 'active_record/dynamic_scope_match'
autoload :Migration, 'active_record/migration'
autoload :Migrator, 'active_record/migration'
autoload :NamedScope, 'active_record/named_scope'
Expand Down
25 changes: 24 additions & 1 deletion activerecord/lib/active_record/base.rb
Expand Up @@ -1456,7 +1456,10 @@ def abstract_class?
def respond_to?(method_id, include_private = false)
if match = DynamicFinderMatch.match(method_id)
return true if all_attributes_exists?(match.attribute_names)
elsif match = DynamicScopeMatch.match(method_id)
return true if all_attributes_exists?(match.attribute_names)
end

super
end

Expand Down Expand Up @@ -1809,7 +1812,11 @@ def undecorated_table_name(class_name = base_class.name)
# This also enables you to initialize a record if it is not found, such as find_or_initialize_by_amount(amount)
# or find_or_create_by_user_and_password(user, password).
#
# Each dynamic finder or initializer/creator is also defined in the class after it is first invoked, so that future
# Also enables dynamic scopes like scoped_by_user_name(user_name) and scoped_by_user_name_and_password(user_name, password) that
# are turned into scoped(:conditions => ["user_name = ?", user_name]) and scoped(:conditions => ["user_name = ? AND password = ?", user_name, password])
# respectively.
#
# Each dynamic finder, scope or initializer/creator is also defined in the class after it is first invoked, so that future
# attempts to use it do not run through method_missing.
def method_missing(method_id, *arguments, &block)
if match = DynamicFinderMatch.match(method_id)
Expand Down Expand Up @@ -1868,6 +1875,22 @@ def self.#{method_id}(*args)
}, __FILE__, __LINE__
send(method_id, *arguments, &block)
end
elsif match = DynamicScopeMatch.match(method_id)
attribute_names = match.attribute_names
super unless all_attributes_exists?(attribute_names)
if match.scope?
self.class_eval %{
def self.#{method_id}(*args) # def self.scoped_by_user_name_and_password(*args)
options = args.extract_options! # options = args.extract_options!
attributes = construct_attributes_from_arguments( # attributes = construct_attributes_from_arguments(
[:#{attribute_names.join(',:')}], args # [:user_name, :password], args
) # )
#
scoped(:conditions => attributes) # scoped(:conditions => attributes)
end # end
}, __FILE__, __LINE__
send(method_id, *arguments)
end
else
super
end
Expand Down
25 changes: 25 additions & 0 deletions activerecord/lib/active_record/dynamic_scope_match.rb
@@ -0,0 +1,25 @@
module ActiveRecord
class DynamicScopeMatch
def self.match(method)
ds_match = self.new(method)
ds_match.scope ? ds_match : nil
end

def initialize(method)
@scope = true
case method.to_s
when /^scoped_by_([_a-zA-Z]\w*)$/
names = $1
else
@scope = nil
end
@attribute_names = names && names.split('_and_')
end

attr_reader :scope, :attribute_names

def scope?
!@scope.nil?
end
end
end
20 changes: 20 additions & 0 deletions activerecord/test/cases/named_scope_test.rb
Expand Up @@ -278,3 +278,23 @@ def test_chaining_with_duplicate_joins
assert_equal post.comments.size, Post.scoped(:joins => join).scoped(:joins => join, :conditions => "posts.id = #{post.id}").size
end
end

class DynamicScopeMatchTest < ActiveRecord::TestCase
def test_scoped_by_no_match
assert_nil ActiveRecord::DynamicScopeMatch.match("not_scoped_at_all")
end

def test_scoped_by
match = ActiveRecord::DynamicScopeMatch.match("scoped_by_age_and_sex_and_location")
assert_not_nil match
assert match.scope?
assert_equal %w(age sex location), match.attribute_names
end
end

class DynamicScopeTest < ActiveRecord::TestCase
def test_dynamic_scope
assert_equal Post.scoped_by_author_id(1).find(1), Post.find(1)

This comment has been minimized.

Copy link
@shingara

shingara Dec 29, 2008

Contributor

it’s not better with assert on :

Post.find(:first, :conditions => { :author_id => 1})

because we can’t assume that Post(1) is allways to author_id(1).

assert_equal Post.scoped_by_author_id_and_title(1, "Welcome to the weblog").first, Post.find(:first, :conditions => { :author_id => 1, :title => "Welcome to the weblog"})
end
end

6 comments on commit 66ee589

@yaroslav
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@shingara well, actually we can since we use fixtures :)

@fabiokung
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest dropping the “scoped_” preffix, as it IMO improves readability:

User.by_name_and_password.find(:all)
User.by_name.by_password.find(:all, :order => 'created_at')

@mereghost
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Still, the “scoped_” prefix allows for clarity of what’s happening.

@joshpencheon
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer it with the “scoped_” prefix, as it’s more in keeping with related declarations (e.g. “named_scope”, “default_scope” etc.)

Using fabiokungs examples, I think it actually rolls of the tongue worse without the prefixes!

@iaccoand
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am ok with “scoped_” prefix

@henrik
Copy link
Contributor

@henrik henrik commented on 66ee589 Feb 21, 2009

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’d agree with fabiokung except that I tend to use “by_x” for named scopes that add an :order. E.g. “by_created”, so having “by_name” use conditions instead of order could get weird, for me at least. “with_” might make more sense for conditions anyway.

To be clear, I don’t mind “scoped_by” or “find_by” since the “by” is prefixed.

Please sign in to comment.