Skip to content
This repository
Browse code

Introduce dynamic scopes for ActiveRecord: you can now use class meth…

…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...
commit 66ee5890c5f21995b7fe0c486547f1287afe2b55 1 parent 1fb2755
Yaroslav Markin authored December 28, 2008 dhh committed December 28, 2008
2  activerecord/CHANGELOG
... ...
@@ -1,5 +1,7 @@
1 1
 *2.3.0/3.0*
2 2
 
  3
+* Added dynamic scopes ala dynamic finders #1648 [Yaroslav Markin]
  4
+
3 5
 * Fixed that ActiveRecord::Base#new_record? should return false (not nil) for existing records #1219 [Yaroslav Markin]
4 6
 
5 7
 * I18n the word separator for error messages. Introduces the activerecord.errors.format.separator translation key.  #1294 [Akira Matsuda]
1  activerecord/lib/active_record.rb
@@ -51,6 +51,7 @@ def self.load_all!
51 51
   autoload :Callbacks, 'active_record/callbacks'
52 52
   autoload :Dirty, 'active_record/dirty'
53 53
   autoload :DynamicFinderMatch, 'active_record/dynamic_finder_match'
  54
+  autoload :DynamicScopeMatch, 'active_record/dynamic_scope_match'
54 55
   autoload :Migration, 'active_record/migration'
55 56
   autoload :Migrator, 'active_record/migration'
56 57
   autoload :NamedScope, 'active_record/named_scope'
25  activerecord/lib/active_record/base.rb
@@ -1456,7 +1456,10 @@ def abstract_class?
1456 1456
       def respond_to?(method_id, include_private = false)
1457 1457
         if match = DynamicFinderMatch.match(method_id)
1458 1458
           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)
1459 1461
         end
  1462
+        
1460 1463
         super
1461 1464
       end
1462 1465
 
@@ -1809,7 +1812,11 @@ def undecorated_table_name(class_name = base_class.name)
1809 1812
         # This also enables you to initialize a record if it is not found, such as find_or_initialize_by_amount(amount)
1810 1813
         # or find_or_create_by_user_and_password(user, password).
1811 1814
         #
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
1813 1820
         # attempts to use it do not run through method_missing.
1814 1821
         def method_missing(method_id, *arguments, &block)
1815 1822
           if match = DynamicFinderMatch.match(method_id)
@@ -1868,6 +1875,22 @@ def self.#{method_id}(*args)
1868 1875
               }, __FILE__, __LINE__
1869 1876
               send(method_id, *arguments, &block)
1870 1877
             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
1871 1894
           else
1872 1895
             super
1873 1896
           end
25  activerecord/lib/active_record/dynamic_scope_match.rb
... ...
@@ -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
20  activerecord/test/cases/named_scope_test.rb
@@ -278,3 +278,23 @@ def test_chaining_with_duplicate_joins
278 278
     assert_equal post.comments.size, Post.scoped(:joins => join).scoped(:joins => join, :conditions => "posts.id = #{post.id}").size
279 279
   end
280 280
 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

6 notes on commit 66ee589

Cyril Mougel

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).

Yaroslav Markin

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

Fabio Kung

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')
Marcello Rocha

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

Josh Pencheon

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!

Andrew Iacco

I am ok with “scoped_” prefix

Henrik Nyh

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.
Something went wrong with that request. Please try again.