Skip to content

Commit 66ee589

Browse files
yaroslavDavid Heinemeier Hansson
authored andcommitted
Introduce dynamic scopes for ActiveRecord: you can now use class methods 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>
1 parent 1fb2755 commit 66ee589

File tree

5 files changed

+72
-1
lines changed

5 files changed

+72
-1
lines changed

activerecord/CHANGELOG

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
*2.3.0/3.0*
22

3+
* Added dynamic scopes ala dynamic finders #1648 [Yaroslav Markin]
4+
35
* Fixed that ActiveRecord::Base#new_record? should return false (not nil) for existing records #1219 [Yaroslav Markin]
46

57
* I18n the word separator for error messages. Introduces the activerecord.errors.format.separator translation key. #1294 [Akira Matsuda]

activerecord/lib/active_record.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def self.load_all!
5151
autoload :Callbacks, 'active_record/callbacks'
5252
autoload :Dirty, 'active_record/dirty'
5353
autoload :DynamicFinderMatch, 'active_record/dynamic_finder_match'
54+
autoload :DynamicScopeMatch, 'active_record/dynamic_scope_match'
5455
autoload :Migration, 'active_record/migration'
5556
autoload :Migrator, 'active_record/migration'
5657
autoload :NamedScope, 'active_record/named_scope'

activerecord/lib/active_record/base.rb

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1456,7 +1456,10 @@ def abstract_class?
14561456
def respond_to?(method_id, include_private = false)
14571457
if match = DynamicFinderMatch.match(method_id)
14581458
return true if all_attributes_exists?(match.attribute_names)
1459+
elsif match = DynamicScopeMatch.match(method_id)
1460+
return true if all_attributes_exists?(match.attribute_names)
14591461
end
1462+
14601463
super
14611464
end
14621465

@@ -1809,7 +1812,11 @@ def undecorated_table_name(class_name = base_class.name)
18091812
# This also enables you to initialize a record if it is not found, such as find_or_initialize_by_amount(amount)
18101813
# or find_or_create_by_user_and_password(user, password).
18111814
#
1812-
# Each dynamic finder or initializer/creator is also defined in the class after it is first invoked, so that future
1815+
# Also enables dynamic scopes like scoped_by_user_name(user_name) and scoped_by_user_name_and_password(user_name, password) that
1816+
# are turned into scoped(:conditions => ["user_name = ?", user_name]) and scoped(:conditions => ["user_name = ? AND password = ?", user_name, password])
1817+
# respectively.
1818+
#
1819+
# Each dynamic finder, scope or initializer/creator is also defined in the class after it is first invoked, so that future
18131820
# attempts to use it do not run through method_missing.
18141821
def method_missing(method_id, *arguments, &block)
18151822
if match = DynamicFinderMatch.match(method_id)
@@ -1868,6 +1875,22 @@ def self.#{method_id}(*args)
18681875
}, __FILE__, __LINE__
18691876
send(method_id, *arguments, &block)
18701877
end
1878+
elsif match = DynamicScopeMatch.match(method_id)
1879+
attribute_names = match.attribute_names
1880+
super unless all_attributes_exists?(attribute_names)
1881+
if match.scope?
1882+
self.class_eval %{
1883+
def self.#{method_id}(*args) # def self.scoped_by_user_name_and_password(*args)
1884+
options = args.extract_options! # options = args.extract_options!
1885+
attributes = construct_attributes_from_arguments( # attributes = construct_attributes_from_arguments(
1886+
[:#{attribute_names.join(',:')}], args # [:user_name, :password], args
1887+
) # )
1888+
#
1889+
scoped(:conditions => attributes) # scoped(:conditions => attributes)
1890+
end # end
1891+
}, __FILE__, __LINE__
1892+
send(method_id, *arguments)
1893+
end
18711894
else
18721895
super
18731896
end
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module ActiveRecord
2+
class DynamicScopeMatch
3+
def self.match(method)
4+
ds_match = self.new(method)
5+
ds_match.scope ? ds_match : nil
6+
end
7+
8+
def initialize(method)
9+
@scope = true
10+
case method.to_s
11+
when /^scoped_by_([_a-zA-Z]\w*)$/
12+
names = $1
13+
else
14+
@scope = nil
15+
end
16+
@attribute_names = names && names.split('_and_')
17+
end
18+
19+
attr_reader :scope, :attribute_names
20+
21+
def scope?
22+
!@scope.nil?
23+
end
24+
end
25+
end

activerecord/test/cases/named_scope_test.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,23 @@ def test_chaining_with_duplicate_joins
278278
assert_equal post.comments.size, Post.scoped(:joins => join).scoped(:joins => join, :conditions => "posts.id = #{post.id}").size
279279
end
280280
end
281+
282+
class DynamicScopeMatchTest < ActiveRecord::TestCase
283+
def test_scoped_by_no_match
284+
assert_nil ActiveRecord::DynamicScopeMatch.match("not_scoped_at_all")
285+
end
286+
287+
def test_scoped_by
288+
match = ActiveRecord::DynamicScopeMatch.match("scoped_by_age_and_sex_and_location")
289+
assert_not_nil match
290+
assert match.scope?
291+
assert_equal %w(age sex location), match.attribute_names
292+
end
293+
end
294+
295+
class DynamicScopeTest < ActiveRecord::TestCase
296+
def test_dynamic_scope
297+
assert_equal Post.scoped_by_author_id(1).find(1), Post.find(1)
298+
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"})
299+
end
300+
end

0 commit comments

Comments
 (0)