diff options
-rw-r--r-- | activemodel/lib/active_model.rb | 1 | ||||
-rw-r--r-- | activemodel/lib/active_model/attribute_methods.rb | 267 | ||||
-rw-r--r-- | activerecord/lib/active_record/attribute_methods.rb | 251 | ||||
-rw-r--r-- | activerecord/lib/active_record/attribute_methods/read.rb | 4 | ||||
-rw-r--r-- | activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb | 4 | ||||
-rw-r--r-- | activerecord/lib/active_record/attribute_methods/write.rb | 2 | ||||
-rwxr-xr-x | activerecord/lib/active_record/base.rb | 5 | ||||
-rw-r--r-- | activerecord/test/cases/attribute_methods_test.rb | 47 | ||||
-rw-r--r-- | activerecord/test/cases/finder_test.rb | 2 |
9 files changed, 284 insertions, 299 deletions
diff --git a/activemodel/lib/active_model.rb b/activemodel/lib/active_model.rb index 2de19597b1..9bb4cf8b54 100644 --- a/activemodel/lib/active_model.rb +++ b/activemodel/lib/active_model.rb @@ -26,6 +26,7 @@ $:.unshift(activesupport_path) if File.directory?(activesupport_path) require 'active_support' module ActiveModel + autoload :AttributeMethods, 'active_model/attribute_methods' autoload :Conversion, 'active_model/conversion' autoload :DeprecatedErrorMethods, 'active_model/deprecated_error_methods' autoload :Errors, 'active_model/errors' diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb new file mode 100644 index 0000000000..de80559036 --- /dev/null +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -0,0 +1,267 @@ +module ActiveModel + class MissingAttributeError < NoMethodError + end + + module AttributeMethods + extend ActiveSupport::Concern + + # Declare and check for suffixed attribute methods. + module ClassMethods + # Defines an "attribute" method (like +inheritance_column+ or + # +table_name+). A new (class) method will be created with the + # given name. If a value is specified, the new method will + # return that value (as a string). Otherwise, the given block + # will be used to compute the value of the method. + # + # The original method will be aliased, with the new name being + # prefixed with "original_". This allows the new method to + # access the original value. + # + # Example: + # + # class A < ActiveRecord::Base + # define_attr_method :primary_key, "sysid" + # define_attr_method( :inheritance_column ) do + # original_inheritance_column + "_id" + # end + # end + def define_attr_method(name, value=nil, &block) + sing = metaclass + sing.send :alias_method, "original_#{name}", name + if block_given? + sing.send :define_method, name, &block + else + # use eval instead of a block to work around a memory leak in dev + # mode in fcgi + sing.class_eval "def #{name}; #{value.to_s.inspect}; end" + end + end + + # Declares a method available for all attributes with the given prefix. + # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method. + # + # #{prefix}#{attr}(*args, &block) + # + # to + # + # #{prefix}attribute(#{attr}, *args, &block) + # + # An <tt>#{prefix}attribute</tt> instance method must exist and accept at least + # the +attr+ argument. + # + # For example: + # + # class Person < ActiveRecord::Base + # attribute_method_prefix 'clear_' + # + # private + # def clear_attribute(attr) + # ... + # end + # end + # + # person = Person.find(1) + # person.name # => 'Gem' + # person.clear_name + # person.name # => '' + def attribute_method_prefix(*prefixes) + attribute_method_matchers.concat(prefixes.map { |prefix| AttributeMethodMatcher.new :prefix => prefix }) + undefine_attribute_methods + end + + # Declares a method available for all attributes with the given suffix. + # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method. + # + # #{attr}#{suffix}(*args, &block) + # + # to + # + # attribute#{suffix}(#{attr}, *args, &block) + # + # An <tt>attribute#{suffix}</tt> instance method must exist and accept at least + # the +attr+ argument. + # + # For example: + # + # class Person < ActiveRecord::Base + # attribute_method_suffix '_short?' + # + # private + # def attribute_short?(attr) + # ... + # end + # end + # + # person = Person.find(1) + # person.name # => 'Gem' + # person.name_short? # => true + def attribute_method_suffix(*suffixes) + attribute_method_matchers.concat(suffixes.map { |suffix| AttributeMethodMatcher.new :suffix => suffix }) + undefine_attribute_methods + end + + # Declares a method available for all attributes with the given prefix + # and suffix. Uses +method_missing+ and <tt>respond_to?</tt> to rewrite + # the method. + # + # #{prefix}#{attr}#{suffix}(*args, &block) + # + # to + # + # #{prefix}attribute#{suffix}(#{attr}, *args, &block) + # + # An <tt>#{prefix}attribute#{suffix}</tt> instance method must exist and + # accept at least the +attr+ argument. + # + # For example: + # + # class Person < ActiveRecord::Base + # attribute_method_affix :prefix => 'reset_', :suffix => '_to_default!' + # + # private + # def reset_attribute_to_default!(attr) + # ... + # end + # end + # + # person = Person.find(1) + # person.name # => 'Gem' + # person.reset_name_to_default! + # person.name # => 'Gemma' + def attribute_method_affix(*affixes) + attribute_method_matchers.concat(affixes.map { |affix| AttributeMethodMatcher.new :prefix => affix[:prefix], :suffix => affix[:suffix] }) + undefine_attribute_methods + end + + def define_attribute_methods(attr_names) + return if attribute_methods_generated? + attr_names.each do |name| + attribute_method_matchers.each do |method| + method_name = "#{method.prefix}#{name}#{method.suffix}" + unless instance_method_already_implemented?(method_name) + generate_method = "define_method_#{method.prefix}attribute#{method.suffix}" + + if respond_to?(generate_method) + send(generate_method, name) + else + generated_attribute_methods.module_eval("def #{method_name}(*args); send(:#{method.prefix}attribute#{method.suffix}, '#{name}', *args); end", __FILE__, __LINE__) + end + end + end + end + end + + def undefine_attribute_methods + generated_attribute_methods.module_eval do + instance_methods.each { |m| undef_method(m) } + end + @attribute_methods_generated = nil + end + + def generated_attribute_methods #:nodoc: + @generated_attribute_methods ||= begin + @attribute_methods_generated = true + mod = Module.new + include mod + mod + end + end + + def attribute_methods_generated? + @attribute_methods_generated ? true : false + end + + protected + def instance_method_already_implemented?(method_name) + method_defined?(method_name) + end + + private + class AttributeMethodMatcher + attr_reader :prefix, :suffix + + AttributeMethodMatch = Struct.new(:prefix, :base, :suffix) + + def initialize(options = {}) + options.symbolize_keys! + @prefix, @suffix = options[:prefix] || '', options[:suffix] || '' + @regex = /^(#{Regexp.escape(@prefix)})(.+?)(#{Regexp.escape(@suffix)})$/ + end + + def match(method_name) + if matchdata = @regex.match(method_name) + AttributeMethodMatch.new(matchdata[1], matchdata[2], matchdata[3]) + else + nil + end + end + end + + def attribute_method_matchers #:nodoc: + @@attribute_method_matchers ||= [] + end + end + + # Allows access to the object attributes, which are held in the <tt>@attributes</tt> hash, as though they + # were first-class methods. So a Person class with a name attribute can use Person#name and + # Person#name= and never directly use the attributes hash -- except for multiple assigns with + # ActiveRecord#attributes=. A Milestone class can also ask Milestone#completed? to test that + # the completed attribute is not +nil+ or 0. + # + # It's also possible to instantiate related objects, so a Client class belonging to the clients + # table with a +master_id+ foreign key can instantiate master through Client#master. + def method_missing(method_id, *args, &block) + method_name = method_id.to_s + if match = match_attribute_method?(method_name) + guard_private_attribute_method!(method_name, args) + return __send__("#{match.prefix}attribute#{match.suffix}", match.base, *args, &block) + end + super + end + + # A Person object with a name attribute can ask <tt>person.respond_to?(:name)</tt>, + # <tt>person.respond_to?(:name=)</tt>, and <tt>person.respond_to?(:name?)</tt> + # which will all return +true+. + alias :respond_to_without_attributes? :respond_to? + def respond_to?(method, include_private_methods = false) + if super + return true + elsif !include_private_methods && super(method, true) + # If we're here then we haven't found among non-private methods + # but found among all methods. Which means that given method is private. + return false + elsif match_attribute_method?(method.to_s) + return true + end + super + end + + protected + def attribute_method?(attr_name) + attributes.include?(attr_name) + end + + private + # Returns a struct representing the matching attribute method. + # The struct's attributes are prefix, base and suffix. + def match_attribute_method?(method_name) + self.class.send(:attribute_method_matchers).each do |method| + if (match = method.match(method_name)) && attribute_method?(match.base) + return match + end + end + nil + end + + # prevent method_missing from calling private methods with #send + def guard_private_attribute_method!(method_name, args) + if self.class.private_method_defined?(method_name) + raise NoMethodError.new("Attempt to call private method", method_name, args) + end + end + + def missing_attribute(attr_name, stack) + raise ActiveModel::MissingAttributeError, "missing attribute: #{attr_name}", stack + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index be275f5cb6..ab7ad34b9e 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -3,191 +3,13 @@ require 'active_support/core_ext/enumerable' module ActiveRecord module AttributeMethods #:nodoc: extend ActiveSupport::Concern + include ActiveModel::AttributeMethods - class AttributeMethodMatcher - attr_reader :prefix, :suffix - - AttributeMethodMatch = Struct.new(:prefix, :base, :suffix) - - def initialize(options = {}) - options.symbolize_keys! - @prefix, @suffix = options[:prefix] || '', options[:suffix] || '' - @regex = /^(#{Regexp.escape(@prefix)})(.+?)(#{Regexp.escape(@suffix)})$/ - end - - def match(method_name) - if matchdata = @regex.match(method_name) - AttributeMethodMatch.new(matchdata[1], matchdata[2], matchdata[3]) - else - nil - end - end - end - - # Declare and check for suffixed attribute methods. module ClassMethods - # Declares a method available for all attributes with the given prefix. - # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method. - # - # #{prefix}#{attr}(*args, &block) - # - # to - # - # #{prefix}attribute(#{attr}, *args, &block) - # - # An <tt>#{prefix}attribute</tt> instance method must exist and accept at least - # the +attr+ argument. - # - # For example: - # - # class Person < ActiveRecord::Base - # attribute_method_prefix 'clear_' - # - # private - # def clear_attribute(attr) - # ... - # end - # end - # - # person = Person.find(1) - # person.name # => 'Gem' - # person.clear_name - # person.name # => '' - def attribute_method_prefix(*prefixes) - attribute_method_matchers.concat(prefixes.map { |prefix| AttributeMethodMatcher.new :prefix => prefix }) - undefine_attribute_methods - end - - # Declares a method available for all attributes with the given suffix. - # Uses +method_missing+ and <tt>respond_to?</tt> to rewrite the method. - # - # #{attr}#{suffix}(*args, &block) - # - # to - # - # attribute#{suffix}(#{attr}, *args, &block) - # - # An <tt>attribute#{suffix}</tt> instance method must exist and accept at least - # the +attr+ argument. - # - # For example: - # - # class Person < ActiveRecord::Base - # attribute_method_suffix '_short?' - # - # private - # def attribute_short?(attr) - # ... - # end - # end - # - # person = Person.find(1) - # person.name # => 'Gem' - # person.name_short? # => true - def attribute_method_suffix(*suffixes) - attribute_method_matchers.concat(suffixes.map { |suffix| AttributeMethodMatcher.new :suffix => suffix }) - undefine_attribute_methods - end - - # Declares a method available for all attributes with the given prefix - # and suffix. Uses +method_missing+ and <tt>respond_to?</tt> to rewrite - # the method. - # - # #{prefix}#{attr}#{suffix}(*args, &block) - # - # to - # - # #{prefix}attribute#{suffix}(#{attr}, *args, &block) - # - # An <tt>#{prefix}attribute#{suffix}</tt> instance method must exist and - # accept at least the +attr+ argument. - # - # For example: - # - # class Person < ActiveRecord::Base - # attribute_method_affix :prefix => 'reset_', :suffix => '_to_default!' - # - # private - # def reset_attribute_to_default!(attr) - # ... - # end - # end - # - # person = Person.find(1) - # person.name # => 'Gem' - # person.reset_name_to_default! - # person.name # => 'Gemma' - def attribute_method_affix(*affixes) - attribute_method_matchers.concat(affixes.map { |affix| AttributeMethodMatcher.new :prefix => affix[:prefix], :suffix => affix[:suffix] }) - undefine_attribute_methods - end - - def matching_attribute_methods(method_name) - attribute_method_matchers.collect { |method| method.match(method_name) }.compact - end - - # Defines an "attribute" method (like +inheritance_column+ or - # +table_name+). A new (class) method will be created with the - # given name. If a value is specified, the new method will - # return that value (as a string). Otherwise, the given block - # will be used to compute the value of the method. - # - # The original method will be aliased, with the new name being - # prefixed with "original_". This allows the new method to - # access the original value. - # - # Example: - # - # class A < ActiveRecord::Base - # define_attr_method :primary_key, "sysid" - # define_attr_method( :inheritance_column ) do - # original_inheritance_column + "_id" - # end - # end - def define_attr_method(name, value=nil, &block) - sing = metaclass - sing.send :alias_method, "original_#{name}", name - if block_given? - sing.send :define_method, name, &block - else - # use eval instead of a block to work around a memory leak in dev - # mode in fcgi - sing.class_eval "def #{name}; #{value.to_s.inspect}; end" - end - end - - def generated_methods #:nodoc: - @generated_methods ||= begin - mod = Module.new - include mod - mod - end - end - # Generates all the attribute related methods for columns in the database # accessors, mutators and query methods. def define_attribute_methods - return unless generated_methods.instance_methods.empty? - columns_hash.keys.each do |name| - attribute_method_matchers.each do |method| - method_name = "#{method.prefix}#{name}#{method.suffix}" - unless instance_method_already_implemented?(method_name) - generate_method = "define_method_#{method.prefix}attribute#{method.suffix}" - - if respond_to?(generate_method) - send(generate_method, name) - else - generated_methods.module_eval("def #{method_name}(*args); send(:#{method.prefix}attribute#{method.suffix}, '#{name}', *args); end", __FILE__, __LINE__) - end - end - end - end - end - - def undefine_attribute_methods - generated_methods.module_eval do - instance_methods.each { |m| undef_method(m) } - end + super(columns_hash.keys) end # Checks whether the method is defined in the model or any of its subclasses @@ -200,83 +22,30 @@ module ActiveRecord raise DangerousAttributeError, "#{method_name} is defined by ActiveRecord" if @@_defined_activerecord_methods.include?(method_name) @_defined_class_methods.include?(method_name) end - - private - # Default to *=, *? and *_before_type_cast - def attribute_method_matchers - @@attribute_method_matchers ||= [] - end - end - - # Returns a struct representing the matching attribute method. - # The struct's attributes are prefix, base and suffix. - def match_attribute_method?(method_name) - self.class.matching_attribute_methods(method_name).find do |match| - match.base == 'id' || @attributes.include?(match.base) - end end - # Allows access to the object attributes, which are held in the <tt>@attributes</tt> hash, as though they - # were first-class methods. So a Person class with a name attribute can use Person#name and - # Person#name= and never directly use the attributes hash -- except for multiple assigns with - # ActiveRecord#attributes=. A Milestone class can also ask Milestone#completed? to test that - # the completed attribute is not +nil+ or 0. - # - # It's also possible to instantiate related objects, so a Client class belonging to the clients - # table with a +master_id+ foreign key can instantiate master through Client#master. def method_missing(method_id, *args, &block) - method_name = method_id.to_s - # If we haven't generated any methods yet, generate them, then # see if we've created the method we're looking for. - if self.class.generated_methods.instance_methods.empty? + if !self.class.attribute_methods_generated? self.class.define_attribute_methods + method_name = method_id.to_s guard_private_attribute_method!(method_name, args) - if self.class.generated_methods.instance_methods.include?(method_name) + if self.class.generated_attribute_methods.instance_methods.include?(method_name) return self.send(method_id, *args, &block) end end - - if match = match_attribute_method?(method_name) - guard_private_attribute_method!(method_name, args) - return __send__("#{match.prefix}attribute#{match.suffix}", match.base, *args, &block) - end super end - # A Person object with a name attribute can ask <tt>person.respond_to?(:name)</tt>, - # <tt>person.respond_to?(:name=)</tt>, and <tt>person.respond_to?(:name?)</tt> - # which will all return +true+. - alias :respond_to_without_attributes? :respond_to? - def respond_to?(method, include_private_methods = false) - method_name = method.to_s - if super - return true - elsif !include_private_methods && super(method, true) - # If we're here then we haven't found among non-private methods - # but found among all methods. Which means that given method is private. - return false - elsif self.class.generated_methods.instance_methods.empty? - self.class.define_attribute_methods - if self.class.generated_methods.instance_methods.include?(method_name) - return true - end - elsif match_attribute_method?(method_name) - return true - end + def respond_to?(*args) + self.class.define_attribute_methods super end - private - # prevent method_missing from calling private methods with #send - def guard_private_attribute_method!(method_name, args) - if self.class.private_method_defined?(method_name) - raise NoMethodError.new("Attempt to call private method", method_name, args) - end - end - - def missing_attribute(attr_name, stack) - raise ActiveRecord::MissingAttributeError, "missing attribute: #{attr_name}", stack + protected + def attribute_method?(attr_name) + attr_name == 'id' || attributes.include?(attr_name) end end end diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index 90acb769a9..0b7d6d9094 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -51,7 +51,7 @@ module ActiveRecord private # Define read method for serialized attribute. def define_read_method_for_serialized_attribute(attr_name) - generated_methods.module_eval("def #{attr_name}; unserialize_attribute('#{attr_name}'); end", __FILE__, __LINE__) + generated_attribute_methods.module_eval("def #{attr_name}; unserialize_attribute('#{attr_name}'); end", __FILE__, __LINE__) end # Define an attribute reader method. Cope with nil column. @@ -66,7 +66,7 @@ module ActiveRecord if cache_attribute?(attr_name) access_code = "@attributes_cache['#{attr_name}'] ||= (#{access_code})" end - generated_methods.module_eval("def #{symbol}; #{access_code}; end", __FILE__, __LINE__) + generated_attribute_methods.module_eval("def #{symbol}; #{access_code}; end", __FILE__, __LINE__) end end diff --git a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb index b9cfe59971..a8e3e28a7a 100644 --- a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb +++ b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb @@ -25,7 +25,7 @@ module ActiveRecord @attributes_cache['#{attr_name}'] = time.acts_like?(:time) ? time.in_time_zone : time end EOV - generated_methods.module_eval(method_body, __FILE__, __LINE__) + generated_attribute_methods.module_eval(method_body, __FILE__, __LINE__) else super end @@ -44,7 +44,7 @@ module ActiveRecord write_attribute(:#{attr_name}, time) end EOV - generated_methods.module_eval(method_body, __FILE__, __LINE__) + generated_attribute_methods.module_eval(method_body, __FILE__, __LINE__) else super end diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb index 79118855cf..e31acac050 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -10,7 +10,7 @@ module ActiveRecord module ClassMethods protected def define_method_attribute=(attr_name) - generated_methods.module_eval("def #{attr_name}=(new_value); write_attribute('#{attr_name}', new_value); end", __FILE__, __LINE__) + generated_attribute_methods.module_eval("def #{attr_name}=(new_value); write_attribute('#{attr_name}', new_value); end", __FILE__, __LINE__) end end diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index ce93ea8eee..e358564ead 100755 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -148,11 +148,6 @@ module ActiveRecord #:nodoc: class DangerousAttributeError < ActiveRecordError end - # Raised when you've tried to access a column which wasn't loaded by your finder. - # Typically this is because <tt>:select</tt> has been specified. - class MissingAttributeError < NoMethodError - end - # Raised when unknown attributes are supplied via mass assignment. class UnknownAttributeError < NoMethodError end diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index a5f4a67200..ab8768ea3e 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -16,53 +16,6 @@ class AttributeMethodsTest < ActiveRecord::TestCase ActiveRecord::Base.send(:attribute_method_matchers).concat(@old_matchers) end - def test_match_attribute_method_query_returns_default_match_data - topic = @target.new(:title => 'Budget') - assert_not_nil match = topic.match_attribute_method?('title=') - assert_equal '', match.prefix - assert_equal 'title', match.base - assert_equal '=', match.suffix - end - - def test_match_attribute_method_query_returns_match_data_for_prefixes - topic = @target.new(:title => 'Budget') - %w(default_ title_).each do |prefix| - @target.class_eval "def #{prefix}attribute(*args) args end" - @target.attribute_method_prefix prefix - - assert_not_nil match = topic.match_attribute_method?("#{prefix}title") - assert_equal prefix, match.prefix - assert_equal 'title', match.base - assert_equal '', match.suffix - end - end - - def test_match_attribute_method_query_returns_match_data_for_suffixes - topic = @target.new(:title => 'Budget') - %w(_default _title_default it! _candidate= _maybe?).each do |suffix| - @target.class_eval "def attribute#{suffix}(*args) args end" - @target.attribute_method_suffix suffix - - assert_not_nil match = topic.match_attribute_method?("title#{suffix}") - assert_equal '', match.prefix - assert_equal 'title', match.base - assert_equal suffix, match.suffix - end - end - - def test_match_attribute_method_query_returns_match_data_for_affixes - topic = @target.new(:title => 'Budget') - [['mark_', '_for_update'], ['reset_', '!'], ['default_', '_value?']].each do |prefix, suffix| - @target.class_eval "def #{prefix}attribute#{suffix}(*args) args end" - @target.attribute_method_affix({ :prefix => prefix, :suffix => suffix }) - - assert_not_nil match = topic.match_attribute_method?("#{prefix}title#{suffix}") - assert_equal prefix, match.prefix - assert_equal 'title', match.base - assert_equal suffix, match.suffix - end - end - def test_undeclared_attribute_method_does_not_affect_respond_to_and_method_missing topic = @target.new(:title => 'Budget') assert topic.respond_to?('title') diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 55ef0d45eb..893fc34c36 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -251,7 +251,7 @@ class FinderTest < ActiveRecord::TestCase def test_find_only_some_columns topic = Topic.find(1, :select => "author_name") - assert_raise(ActiveRecord::MissingAttributeError) {topic.title} + assert_raise(ActiveModel::MissingAttributeError) {topic.title} assert_equal "David", topic.author_name assert !topic.attribute_present?("title") #assert !topic.respond_to?("title") |