Skip to content
This repository
Browse code

Modified ActiveRecord::AttributeMethods to allow classes to specify a…

…ttribute method prefixes and/or suffixes. Previously only suffixes were allowed.

Signed-off-by: Joshua Peek <josh@joshpeek.com>
  • Loading branch information...
commit c30a0ce3c8f88baebd369180a6e221706e2b5cbf 1 parent aad5a30
Paul Gillard authored August 04, 2009 josh committed August 04, 2009
154  activerecord/lib/active_record/attribute_methods.rb
@@ -4,10 +4,62 @@ module ActiveRecord
4 4
   module AttributeMethods #:nodoc:
5 5
     extend ActiveSupport::Concern
6 6
 
  7
+    class AttributeMethodMatcher
  8
+      attr_reader :prefix, :suffix
  9
+      
  10
+      AttributeMethodMatch = Struct.new(:prefix, :base, :suffix)
  11
+
  12
+      def initialize(options = {})
  13
+        options.symbolize_keys!
  14
+        @prefix, @suffix = options[:prefix] || '', options[:suffix] || ''
  15
+        @regex = /^(#{Regexp.escape(@prefix)})(.+?)(#{Regexp.escape(@suffix)})$/
  16
+      end
  17
+
  18
+      def match(method_name)
  19
+        if matchdata = @regex.match(method_name)
  20
+          AttributeMethodMatch.new(matchdata[1], matchdata[2], matchdata[3])
  21
+        else
  22
+          nil
  23
+        end
  24
+      end
  25
+    end
  26
+
7 27
     # Declare and check for suffixed attribute methods.
8 28
     module ClassMethods
  29
+      # Declares a method available for all attributes with the given prefix.
  30
+      # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method.
  31
+      #
  32
+      #   #{prefix}#{attr}(*args, &block)
  33
+      #
  34
+      # to
  35
+      #
  36
+      #   #{prefix}attribute(#{attr}, *args, &block)
  37
+      #
  38
+      # An <tt>#{prefix}attribute</tt> instance method must exist and accept at least
  39
+      # the +attr+ argument.
  40
+      #
  41
+      # For example:
  42
+      #
  43
+      #   class Person < ActiveRecord::Base
  44
+      #     attribute_method_prefix 'clear_'
  45
+      #
  46
+      #     private
  47
+      #       def clear_attribute(attr)
  48
+      #         ...
  49
+      #       end
  50
+      #   end
  51
+      #
  52
+      #   person = Person.find(1)
  53
+      #   person.name          # => 'Gem'
  54
+      #   person.clear_name
  55
+      #   person.name          # => ''
  56
+      def attribute_method_prefix(*prefixes)
  57
+        attribute_method_matchers.concat(prefixes.map { |prefix| AttributeMethodMatcher.new :prefix => prefix })
  58
+        undefine_attribute_methods
  59
+      end
  60
+
9 61
       # Declares a method available for all attributes with the given suffix.
10  
-      # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method
  62
+      # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method.
11 63
       #
12 64
       #   #{attr}#{suffix}(*args, &block)
13 65
       #
@@ -21,24 +73,59 @@ module ClassMethods
21 73
       # For example:
22 74
       #
23 75
       #   class Person < ActiveRecord::Base
24  
-      #     attribute_method_suffix '_changed?'
  76
+      #     attribute_method_suffix '_short?'
25 77
       #
26 78
       #     private
27  
-      #       def attribute_changed?(attr)
  79
+      #       def attribute_short?(attr)
28 80
       #         ...
29 81
       #       end
30 82
       #   end
31 83
       #
32 84
       #   person = Person.find(1)
33  
-      #   person.name_changed?    # => false
34  
-      #   person.name = 'Hubert'
35  
-      #   person.name_changed?    # => true
  85
+      #   person.name           # => 'Gem'
  86
+      #   person.name_short?    # => true
36 87
       def attribute_method_suffix(*suffixes)
37  
-        attribute_method_suffixes.concat(suffixes)
38  
-        rebuild_attribute_method_regexp
  88
+        attribute_method_matchers.concat(suffixes.map { |suffix| AttributeMethodMatcher.new :suffix => suffix })
39 89
         undefine_attribute_methods
40 90
       end
41 91
 
  92
+      # Declares a method available for all attributes with the given prefix
  93
+      # and suffix. Uses +method_missing+ and <tt>respond_to?</tt> to rewrite
  94
+      # the method.
  95
+      #
  96
+      #   #{prefix}#{attr}#{suffix}(*args, &block)
  97
+      #
  98
+      # to
  99
+      #
  100
+      #   #{prefix}attribute#{suffix}(#{attr}, *args, &block)
  101
+      #
  102
+      # An <tt>#{prefix}attribute#{suffix}</tt> instance method must exist and
  103
+      # accept at least the +attr+ argument.
  104
+      #
  105
+      # For example:
  106
+      #
  107
+      #   class Person < ActiveRecord::Base
  108
+      #     attribute_method_affix :prefix => 'reset_', :suffix => '_to_default!'
  109
+      #
  110
+      #     private
  111
+      #       def reset_attribute_to_default!(attr)
  112
+      #         ...
  113
+      #       end
  114
+      #   end
  115
+      #
  116
+      #   person = Person.find(1)
  117
+      #   person.name                         # => 'Gem'
  118
+      #   person.reset_name_to_default!
  119
+      #   person.name                         # => 'Gemma'
  120
+      def attribute_method_affix(*affixes)
  121
+        attribute_method_matchers.concat(affixes.map { |affix| AttributeMethodMatcher.new :prefix => affix[:prefix], :suffix => affix[:suffix] })
  122
+        undefine_attribute_methods
  123
+      end
  124
+      
  125
+      def matching_attribute_methods(method_name)
  126
+        attribute_method_matchers.collect { |method| method.match(method_name) }.compact
  127
+      end
  128
+      
42 129
       # Defines an "attribute" method (like +inheritance_column+ or
43 130
       # +table_name+). A new (class) method will be created with the
44 131
       # given name. If a value is specified, the new method will
@@ -69,12 +156,6 @@ def define_attr_method(name, value=nil, &block)
69 156
         end
70 157
       end
71 158
 
72  
-      # Returns MatchData if method_name is an attribute method.
73  
-      def match_attribute_method?(method_name)
74  
-        rebuild_attribute_method_regexp unless defined?(@@attribute_method_regexp) && @@attribute_method_regexp
75  
-        @@attribute_method_regexp.match(method_name)
76  
-      end
77  
-
78 159
       def generated_methods #:nodoc:
79 160
         @generated_methods ||= begin
80 161
           mod = Module.new
@@ -88,14 +169,15 @@ def generated_methods #:nodoc:
88 169
       def define_attribute_methods
89 170
         return unless generated_methods.instance_methods.empty?
90 171
         columns_hash.keys.each do |name|
91  
-          attribute_method_suffixes.each do |suffix|
92  
-            method_name = "#{name}#{suffix}"
  172
+          attribute_method_matchers.each do |method|
  173
+            method_name = "#{method.prefix}#{name}#{method.suffix}"
93 174
             unless instance_method_already_implemented?(method_name)
94  
-              generate_method = "define_attribute_method#{suffix}"
  175
+              generate_method = "define_method_#{method.prefix}attribute#{method.suffix}"
  176
+              
95 177
               if respond_to?(generate_method)
96 178
                 send(generate_method, name)
97 179
               else
98  
-                generated_methods.module_eval("def #{method_name}(*args); send(:attribute#{suffix}, '#{name}', *args); end", __FILE__, __LINE__)
  180
+                generated_methods.module_eval("def #{method_name}(*args); send(:#{method.prefix}attribute#{method.suffix}, '#{name}', *args); end", __FILE__, __LINE__)
99 181
               end
100 182
             end
101 183
           end
@@ -120,17 +202,20 @@ def instance_method_already_implemented?(method_name)
120 202
       end
121 203
 
122 204
       private
123  
-        # Suffixes a, ?, c become regexp /(a|\?|c)$/
124  
-        def rebuild_attribute_method_regexp
125  
-          suffixes = attribute_method_suffixes.map { |s| Regexp.escape(s) }
126  
-          @@attribute_method_regexp = /(#{suffixes.join('|')})$/.freeze
127  
-        end
128  
-
129  
-        def attribute_method_suffixes
130  
-          @@attribute_method_suffixes ||= []
  205
+        # Default to *=, *? and *_before_type_cast
  206
+        def attribute_method_matchers
  207
+          @@attribute_method_matchers ||= []
131 208
         end
132 209
     end
133 210
 
  211
+    # Returns a struct representing the matching attribute method.
  212
+    # The struct's attributes are prefix, base and suffix.
  213
+    def match_attribute_method?(method_name)
  214
+      self.class.matching_attribute_methods(method_name).find do |match|
  215
+        match.base == 'id' || @attributes.include?(match.base)
  216
+      end
  217
+    end
  218
+    
134 219
     # Allows access to the object attributes, which are held in the <tt>@attributes</tt> hash, as though they
135 220
     # were first-class methods. So a Person class with a name attribute can use Person#name and
136 221
     # Person#name= and never directly use the attributes hash -- except for multiple assigns with
@@ -152,12 +237,9 @@ def method_missing(method_id, *args, &block)
152 237
         end
153 238
       end
154 239
 
155  
-      if md = self.class.match_attribute_method?(method_name)
156  
-        attribute_name, method_type = md.pre_match, md.to_s
157  
-        if attribute_name == 'id' || @attributes.include?(attribute_name)
158  
-          guard_private_attribute_method!(method_name, args)
159  
-          return __send__("attribute#{method_type}", attribute_name, *args, &block)
160  
-        end
  240
+      if match = match_attribute_method?(method_name)
  241
+        guard_private_attribute_method!(method_name, args)
  242
+        return __send__("#{match.prefix}attribute#{match.suffix}", match.base, *args, &block)
161 243
       end
162 244
       super
163 245
     end
@@ -171,7 +253,7 @@ def respond_to?(method, include_private_methods = false)
171 253
       if super
172 254
         return true
173 255
       elsif !include_private_methods && super(method, true)
174  
-        # If we're here than we haven't found among non-private methods
  256
+        # If we're here then we haven't found among non-private methods
175 257
         # but found among all methods. Which means that given method is private.
176 258
         return false
177 259
       elsif self.class.generated_methods.instance_methods.empty?
@@ -179,10 +261,8 @@ def respond_to?(method, include_private_methods = false)
179 261
         if self.class.generated_methods.instance_methods.include?(method_name)
180 262
           return true
181 263
         end
182  
-      end
183  
-
184  
-      if md = self.class.match_attribute_method?(method_name)
185  
-        return true if md.pre_match == 'id' || @attributes.include?(md.pre_match)
  264
+      elsif match_attribute_method?(method_name)
  265
+        return true
186 266
       end
187 267
       super
188 268
     end
2  activerecord/lib/active_record/attribute_methods/read.rb
@@ -36,7 +36,7 @@ def cache_attribute?(attr_name)
36 36
         end
37 37
 
38 38
         protected
39  
-          def define_attribute_method(attr_name)
  39
+          def define_method_attribute(attr_name)
40 40
             if self.serialized_attributes[attr_name]
41 41
               define_read_method_for_serialized_attribute(attr_name)
42 42
             else
4  activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb
@@ -15,7 +15,7 @@ module ClassMethods
15 15
         protected
16 16
           # Defined for all +datetime+ and +timestamp+ attributes when +time_zone_aware_attributes+ are enabled.
17 17
           # This enhanced read method automatically converts the UTC time stored in the database to the time zone stored in Time.zone.
18  
-          def define_attribute_method(attr_name)
  18
+          def define_method_attribute(attr_name)
19 19
             if create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name])
20 20
               method_body = <<-EOV
21 21
                 def #{attr_name}(reload = false)
@@ -33,7 +33,7 @@ def #{attr_name}(reload = false)
33 33
 
34 34
           # Defined for all +datetime+ and +timestamp+ attributes when +time_zone_aware_attributes+ are enabled.
35 35
           # This enhanced write method will automatically convert the time passed to it to the zone stored in Time.zone.
36  
-          def define_attribute_method=(attr_name)
  36
+          def define_method_attribute=(attr_name)
37 37
             if create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name])
38 38
               method_body = <<-EOV
39 39
                 def #{attr_name}=(time)
2  activerecord/lib/active_record/attribute_methods/write.rb
@@ -9,7 +9,7 @@ module Write
9 9
 
10 10
       module ClassMethods
11 11
         protected
12  
-          def define_attribute_method=(attr_name)
  12
+          def define_method_attribute=(attr_name)
13 13
             generated_methods.module_eval("def #{attr_name}=(new_value); write_attribute('#{attr_name}', new_value); end", __FILE__, __LINE__)
14 14
           end
15 15
       end
94  activerecord/test/cases/attribute_methods_test.rb
@@ -4,40 +4,90 @@
4 4
 
5 5
 class AttributeMethodsTest < ActiveRecord::TestCase
6 6
   fixtures :topics
  7
+  
7 8
   def setup
8  
-    @old_suffixes = ActiveRecord::Base.send(:attribute_method_suffixes).dup
  9
+    @old_matchers = ActiveRecord::Base.send(:attribute_method_matchers).dup
9 10
     @target = Class.new(ActiveRecord::Base)
10 11
     @target.table_name = 'topics'
11 12
   end
12 13
 
13 14
   def teardown
14  
-    ActiveRecord::Base.send(:attribute_method_suffixes).clear
15  
-    ActiveRecord::Base.attribute_method_suffix *@old_suffixes
  15
+    ActiveRecord::Base.send(:attribute_method_matchers).clear
  16
+    ActiveRecord::Base.send(:attribute_method_matchers).concat(@old_matchers)
16 17
   end
17 18
 
18  
-  def test_match_attribute_method_query_returns_match_data
19  
-    assert_not_nil md = @target.match_attribute_method?('title=')
20  
-    assert_equal 'title', md.pre_match
21  
-    assert_equal ['='], md.captures
22  
-
23  
-    %w(_hello_world ist! _maybe?).each do |suffix|
  19
+  def test_match_attribute_method_query_returns_default_match_data
  20
+    topic = @target.new(:title => 'Budget')
  21
+    assert_not_nil match = topic.match_attribute_method?('title=')
  22
+    assert_equal '', match.prefix
  23
+    assert_equal 'title', match.base
  24
+    assert_equal '=', match.suffix
  25
+  end
  26
+  
  27
+  def test_match_attribute_method_query_returns_match_data_for_prefixes
  28
+    topic = @target.new(:title => 'Budget')
  29
+    %w(default_ title_).each do |prefix|
  30
+      @target.class_eval "def #{prefix}attribute(*args) args end"
  31
+      @target.attribute_method_prefix prefix
  32
+
  33
+      assert_not_nil match = topic.match_attribute_method?("#{prefix}title")
  34
+      assert_equal prefix, match.prefix
  35
+      assert_equal 'title', match.base
  36
+      assert_equal '', match.suffix
  37
+    end
  38
+  end
  39
+  
  40
+  def test_match_attribute_method_query_returns_match_data_for_suffixes
  41
+    topic = @target.new(:title => 'Budget')
  42
+    %w(_default _title_default it! _candidate=  _maybe?).each do |suffix|
24 43
       @target.class_eval "def attribute#{suffix}(*args) args end"
25 44
       @target.attribute_method_suffix suffix
26 45
 
27  
-      assert_not_nil md = @target.match_attribute_method?("title#{suffix}")
28  
-      assert_equal 'title', md.pre_match
29  
-      assert_equal [suffix], md.captures
  46
+      assert_not_nil match = topic.match_attribute_method?("title#{suffix}")
  47
+      assert_equal '', match.prefix
  48
+      assert_equal 'title', match.base
  49
+      assert_equal suffix, match.suffix
30 50
     end
31 51
   end
32  
-
33  
-  def test_declared_attribute_method_affects_respond_to_and_method_missing
  52
+  
  53
+  def test_match_attribute_method_query_returns_match_data_for_affixes
  54
+    topic = @target.new(:title => 'Budget')
  55
+    [['mark_', '_for_update'], ['reset_', '!'], ['default_', '_value?']].each do |prefix, suffix|
  56
+      @target.class_eval "def #{prefix}attribute#{suffix}(*args) args end"
  57
+      @target.attribute_method_affix({ :prefix => prefix, :suffix => suffix })
  58
+
  59
+      assert_not_nil match = topic.match_attribute_method?("#{prefix}title#{suffix}")
  60
+      assert_equal prefix, match.prefix
  61
+      assert_equal 'title', match.base
  62
+      assert_equal suffix, match.suffix
  63
+    end
  64
+  end
  65
+  
  66
+  def test_undeclared_attribute_method_does_not_affect_respond_to_and_method_missing
34 67
     topic = @target.new(:title => 'Budget')
35 68
     assert topic.respond_to?('title')
36 69
     assert_equal 'Budget', topic.title
37 70
     assert !topic.respond_to?('title_hello_world')
38 71
     assert_raise(NoMethodError) { topic.title_hello_world }
  72
+  end
39 73
 
40  
-    %w(_hello_world _it! _candidate= able?).each do |suffix|
  74
+  def test_declared_prefixed_attribute_method_affects_respond_to_and_method_missing
  75
+    topic = @target.new(:title => 'Budget')
  76
+    %w(default_ title_).each do |prefix|
  77
+      @target.class_eval "def #{prefix}attribute(*args) args end"
  78
+      @target.attribute_method_prefix prefix
  79
+
  80
+      meth = "#{prefix}title"
  81
+      assert topic.respond_to?(meth)
  82
+      assert_equal ['title'], topic.send(meth)
  83
+      assert_equal ['title', 'a'], topic.send(meth, 'a')
  84
+      assert_equal ['title', 1, 2, 3], topic.send(meth, 1, 2, 3)
  85
+    end
  86
+  end
  87
+
  88
+  def test_declared_suffixed_attribute_method_affects_respond_to_and_method_missing
  89
+    topic = @target.new(:title => 'Budget')
  90
+    %w(_default _title_default _it! _candidate= able?).each do |suffix|
41 91
       @target.class_eval "def attribute#{suffix}(*args) args end"
42 92
       @target.attribute_method_suffix suffix
43 93
 
@@ -49,6 +99,20 @@ def test_declared_attribute_method_affects_respond_to_and_method_missing
49 99
     end
50 100
   end
51 101
 
  102
+  def test_declared_affixed_attribute_method_affects_respond_to_and_method_missing
  103
+    topic = @target.new(:title => 'Budget')
  104
+    [['mark_', '_for_update'], ['reset_', '!'], ['default_', '_value?']].each do |prefix, suffix|
  105
+      @target.class_eval "def #{prefix}attribute#{suffix}(*args) args end"
  106
+      @target.attribute_method_affix({ :prefix => prefix, :suffix => suffix })
  107
+
  108
+      meth = "#{prefix}title#{suffix}"
  109
+      assert topic.respond_to?(meth)
  110
+      assert_equal ['title'], topic.send(meth)
  111
+      assert_equal ['title', 'a'], topic.send(meth, 'a')
  112
+      assert_equal ['title', 1, 2, 3], topic.send(meth, 1, 2, 3)
  113
+    end
  114
+  end
  115
+
52 116
   def test_should_unserialize_attributes_for_frozen_records
53 117
     myobj = {:value1 => :value2}
54 118
     topic = Topic.create("content" => myobj)

0 notes on commit c30a0ce

Please sign in to comment.
Something went wrong with that request. Please try again.