From 2b3cc2478fff77fdada9adeabc32ecea0605d7bd Mon Sep 17 00:00:00 2001 From: Jeremy Kemper Date: Mon, 31 Jul 2006 03:43:03 +0000 Subject: [PATCH] r4854@ks: jeremy | 2006-07-30 00:59:18 -0700 Attribute methods r4877@ks: jeremy | 2006-07-30 20:23:53 -0700 Factor the attribute#{suffix} methods out of method_missing for easier extension. r4878@ks: jeremy | 2006-07-30 20:42:23 -0700 More specific method naming, declare many attribute method suffixes, set up default suffixes at module include rather than lazily. git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@4632 5ecf4fe2-1ee6-0310-87b1-e25e094e27de --- activerecord/CHANGELOG | 2 + activerecord/lib/active_record.rb | 2 + .../lib/active_record/attribute_methods.rb | 75 +++++++++++++++++++ activerecord/lib/active_record/base.rb | 14 ++-- activerecord/test/attribute_methods_test.rb | 30 ++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 activerecord/lib/active_record/attribute_methods.rb create mode 100755 activerecord/test/attribute_methods_test.rb diff --git a/activerecord/CHANGELOG b/activerecord/CHANGELOG index 10387d04ef..7900524a74 100644 --- a/activerecord/CHANGELOG +++ b/activerecord/CHANGELOG @@ -1,5 +1,7 @@ *SVN* +* Factor the attribute#{suffix} methods out of method_missing for easier extension. [Jeremy Kemper] + * Patch sql injection vulnerability when using integer or float columns. [Jamis Buck] * Allow #count through a has_many association to accept :include. [Dan Peterson] diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 0fb36dbfb3..34ab69f872 100755 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -52,6 +52,7 @@ require 'active_record/migration' require 'active_record/schema' require 'active_record/calculations' require 'active_record/xml_serialization' +require 'active_record/attribute_methods' ActiveRecord::Base.class_eval do include ActiveRecord::Validations @@ -69,6 +70,7 @@ ActiveRecord::Base.class_eval do include ActiveRecord::Acts::NestedSet include ActiveRecord::Calculations include ActiveRecord::XmlSerialization + include ActiveRecord::AttributeMethods end unless defined?(RAILS_CONNECTION_ADAPTERS) diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb new file mode 100644 index 0000000000..adc6eb6559 --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -0,0 +1,75 @@ +module ActiveRecord + module AttributeMethods #:nodoc: + DEFAULT_SUFFIXES = %w(= ? _before_type_cast) + + def self.included(base) + base.extend ClassMethods + base.attribute_method_suffix *DEFAULT_SUFFIXES + end + + # Declare and check for suffixed attribute methods. + module ClassMethods + # Declare a method available for all attributes with the given suffix. + # Uses method_missing and respond_to? to rewrite the method + # #{attr}#{suffix}(*args, &block) + # to + # attribute#{suffix}(#{attr}, *args, &block) + # + # An attribute#{suffix} instance method must exist and accept at least + # the attr argument. + # + # For example: + # class Person < ActiveRecord::Base + # attribute_method_suffix '_changed?' + # + # private + # def attribute_changed?(attr) + # ... + # end + # end + # + # person = Person.find(1) + # person.name_changed? # => false + # person.name = 'Hubert' + # person.name_changed? # => true + def attribute_method_suffix(*suffixes) + attribute_method_suffixes.concat suffixes + rebuild_attribute_method_regexp + end + + # Returns MatchData if method_name is an attribute method. + def match_attribute_method?(method_name) + rebuild_attribute_method_regexp unless defined?(@@attribute_method_regexp) && @@attribute_method_regexp + @@attribute_method_regexp.match(method_name) + end + + private + # Suffixes a, ?, c become regexp /(a|\?|c)$/ + def rebuild_attribute_method_regexp + suffixes = attribute_method_suffixes.map { |s| Regexp.escape(s) } + @@attribute_method_regexp = /(#{suffixes.join('|')})$/.freeze + end + + # Default to =, ?, _before_type_cast + def attribute_method_suffixes + @@attribute_method_suffixes ||= [] + end + end + + private + # Handle *? for method_missing. + def attribute?(attribute_name) + query_attribute(attribute_name) + end + + # Handle *= for method_missing. + def attribute=(attribute_name, value) + write_attribute(attribute_name, value) + end + + # Handle *_before_type_cast for method_missing. + def attribute_before_type_cast(attribute_name) + read_attribute_before_type_cast(attribute_name) + end + end +end diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index b852a0354f..820e6a80ce 100755 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -1671,13 +1671,13 @@ module ActiveRecord #:nodoc: # person.respond_to?("name?") which will all return true. def respond_to?(method, include_priv = false) if @attributes.nil? - return super + return super elsif attr_name = self.class.column_methods_hash[method.to_sym] return true if @attributes.include?(attr_name) || attr_name == self.class.primary_key return false if self.class.read_methods.include?(attr_name) elsif @attributes.include?(method_name = method.to_s) return true - elsif md = /(=|\?|_before_type_cast)$/.match(method_name) + elsif md = self.class.match_attribute_method?(method.to_s) return true if @attributes.include?(md.pre_match) end # super must be called at the end of the method, because the inherited respond_to? @@ -1750,6 +1750,7 @@ module ActiveRecord #:nodoc: end end + # Allows access to the object attributes, which are held in the @attributes hash, as were # they 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 @@ -1767,15 +1768,10 @@ module ActiveRecord #:nodoc: md ? query_attribute(method_name) : read_attribute(method_name) elsif self.class.primary_key.to_s == method_name id - elsif md = /(=|_before_type_cast)$/.match(method_name) + elsif md = self.class.match_attribute_method?(method_name) attribute_name, method_type = md.pre_match, md.to_s if @attributes.include?(attribute_name) - case method_type - when '=' - write_attribute(attribute_name, args.first) - when '_before_type_cast' - read_attribute_before_type_cast(attribute_name) - end + __send__("attribute#{method_type}", attribute_name, *args, &block) else super end diff --git a/activerecord/test/attribute_methods_test.rb b/activerecord/test/attribute_methods_test.rb new file mode 100755 index 0000000000..8dcca2fc4a --- /dev/null +++ b/activerecord/test/attribute_methods_test.rb @@ -0,0 +1,30 @@ +require 'abstract_unit' + +class AttributeMethodsTest < Test::Unit::TestCase + def setup + @target = Class.new(ActiveRecord::Base) + @target.table_name = 'topics' + end + + def test_match_attribute_method_query_returns_match_data + assert_not_nil md = @target.match_attribute_method?('title=') + assert_equal 'title', md.pre_match + assert_equal ['='], md.captures + end + + def test_declared_attribute_method_affects_respond_to_and_method_missing + topic = @target.new(:title => 'Budget') + assert topic.respond_to?('title') + assert_equal 'Budget', topic.title + assert !topic.respond_to?('title_hello_world') + assert_raise(NoMethodError) { topic.title_hello_world } + + @target.class_eval "def attribute_hello_world(*args) args end" + @target.attribute_method_suffix '_hello_world' + + assert topic.respond_to?('title_hello_world') + assert_equal ['title'], topic.title_hello_world + assert_equal ['title', 'a'], topic.title_hello_world('a') + assert_equal ['title', 1, 2, 3], topic.title_hello_world(1, 2, 3) + end +end