diff options
Diffstat (limited to 'activemodel/lib/active_model/attribute_methods.rb')
-rw-r--r-- | activemodel/lib/active_model/attribute_methods.rb | 480 |
1 files changed, 480 insertions, 0 deletions
diff --git a/activemodel/lib/active_model/attribute_methods.rb b/activemodel/lib/active_model/attribute_methods.rb new file mode 100644 index 0000000000..ea07c5c039 --- /dev/null +++ b/activemodel/lib/active_model/attribute_methods.rb @@ -0,0 +1,480 @@ +require 'thread_safe' +require 'mutex_m' + +module ActiveModel + # Raised when an attribute is not defined. + # + # class User < ActiveRecord::Base + # has_many :pets + # end + # + # user = User.first + # user.pets.select(:id).first.user_id + # # => ActiveModel::MissingAttributeError: missing attribute: user_id + class MissingAttributeError < NoMethodError + end + + # == Active \Model \Attribute \Methods + # + # Provides a way to add prefixes and suffixes to your methods as + # well as handling the creation of <tt>ActiveRecord::Base</tt>-like + # class methods such as +table_name+. + # + # The requirements to implement <tt>ActiveModel::AttributeMethods</tt> are to: + # + # * <tt>include ActiveModel::AttributeMethods</tt> in your class. + # * Call each of its method you want to add, such as +attribute_method_suffix+ + # or +attribute_method_prefix+. + # * Call +define_attribute_methods+ after the other methods are called. + # * Define the various generic +_attribute+ methods that you have declared. + # * Define an +attributes+ method which returns a hash with each + # attribute name in your model as hash key and the attribute value as hash value. + # Hash keys must be strings. + # + # A minimal implementation could be: + # + # class Person + # include ActiveModel::AttributeMethods + # + # attribute_method_affix prefix: 'reset_', suffix: '_to_default!' + # attribute_method_suffix '_contrived?' + # attribute_method_prefix 'clear_' + # define_attribute_methods :name + # + # attr_accessor :name + # + # def attributes + # { 'name' => @name } + # end + # + # private + # + # def attribute_contrived?(attr) + # true + # end + # + # def clear_attribute(attr) + # send("#{attr}=", nil) + # end + # + # def reset_attribute_to_default!(attr) + # send("#{attr}=", 'Default Name') + # end + # end + module AttributeMethods + extend ActiveSupport::Concern + + NAME_COMPILABLE_REGEXP = /\A[a-zA-Z_]\w*[!?=]?\z/ + CALL_COMPILABLE_REGEXP = /\A[a-zA-Z_]\w*[!?]?\z/ + + included do + class_attribute :attribute_aliases, :attribute_method_matchers, instance_writer: false + self.attribute_aliases = {} + self.attribute_method_matchers = [ClassMethods::AttributeMethodMatcher.new] + end + + 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 instance method <tt>#{prefix}attribute</tt> must exist and accept + # at least the +attr+ argument. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_prefix 'clear_' + # define_attribute_methods :name + # + # private + # + # def clear_attribute(attr) + # send("#{attr}=", nil) + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name # => "Bob" + # person.clear_name + # person.name # => nil + def attribute_method_prefix(*prefixes) + self.attribute_method_matchers += 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. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_suffix '_short?' + # define_attribute_methods :name + # + # private + # + # def attribute_short?(attr) + # send(attr).length < 5 + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name # => "Bob" + # person.name_short? # => true + def attribute_method_suffix(*suffixes) + self.attribute_method_matchers += 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. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_affix prefix: 'reset_', suffix: '_to_default!' + # define_attribute_methods :name + # + # private + # + # def reset_attribute_to_default!(attr) + # send("#{attr}=", 'Default Name') + # end + # end + # + # person = Person.new + # person.name # => 'Gem' + # person.reset_name_to_default! + # person.name # => 'Default Name' + def attribute_method_affix(*affixes) + self.attribute_method_matchers += affixes.map! { |affix| AttributeMethodMatcher.new prefix: affix[:prefix], suffix: affix[:suffix] } + undefine_attribute_methods + end + + # Allows you to make aliases for attributes. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_suffix '_short?' + # define_attribute_methods :name + # + # alias_attribute :nickname, :name + # + # private + # + # def attribute_short?(attr) + # send(attr).length < 5 + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name # => "Bob" + # person.nickname # => "Bob" + # person.name_short? # => true + # person.nickname_short? # => true + def alias_attribute(new_name, old_name) + self.attribute_aliases = attribute_aliases.merge(new_name.to_s => old_name.to_s) + attribute_method_matchers.each do |matcher| + matcher_new = matcher.method_name(new_name).to_s + matcher_old = matcher.method_name(old_name).to_s + define_proxy_call false, self, matcher_new, matcher_old + end + end + + # Is +new_name+ an alias? + def attribute_alias?(new_name) + attribute_aliases.key? new_name.to_s + end + + # Returns the original name for the alias +name+ + def attribute_alias(name) + attribute_aliases[name.to_s] + end + + # Declares the attributes that should be prefixed and suffixed by + # ActiveModel::AttributeMethods. + # + # To use, pass attribute names (as strings or symbols), be sure to declare + # +define_attribute_methods+ after you define any prefix, suffix or affix + # methods, or they will not hook in. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name, :age, :address + # attribute_method_prefix 'clear_' + # + # # Call to define_attribute_methods must appear after the + # # attribute_method_prefix, attribute_method_suffix or + # # attribute_method_affix declares. + # define_attribute_methods :name, :age, :address + # + # private + # + # def clear_attribute(attr) + # send("#{attr}=", nil) + # end + # end + def define_attribute_methods(*attr_names) + attr_names.flatten.each { |attr_name| define_attribute_method(attr_name) } + end + + # Declares an attribute that should be prefixed and suffixed by + # ActiveModel::AttributeMethods. + # + # To use, pass an attribute name (as string or symbol), be sure to declare + # +define_attribute_method+ after you define any prefix, suffix or affix + # method, or they will not hook in. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_suffix '_short?' + # + # # Call to define_attribute_method must appear after the + # # attribute_method_prefix, attribute_method_suffix or + # # attribute_method_affix declares. + # define_attribute_method :name + # + # private + # + # def attribute_short?(attr) + # send(attr).length < 5 + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name # => "Bob" + # person.name_short? # => true + def define_attribute_method(attr_name) + attribute_method_matchers.each do |matcher| + method_name = matcher.method_name(attr_name) + + unless instance_method_already_implemented?(method_name) + generate_method = "define_method_#{matcher.method_missing_target}" + + if respond_to?(generate_method, true) + send(generate_method, attr_name) + else + define_proxy_call true, generated_attribute_methods, method_name, matcher.method_missing_target, attr_name.to_s + end + end + end + attribute_method_matchers_cache.clear + end + + # Removes all the previously dynamically defined methods from the class. + # + # class Person + # include ActiveModel::AttributeMethods + # + # attr_accessor :name + # attribute_method_suffix '_short?' + # define_attribute_method :name + # + # private + # + # def attribute_short?(attr) + # send(attr).length < 5 + # end + # end + # + # person = Person.new + # person.name = 'Bob' + # person.name_short? # => true + # + # Person.undefine_attribute_methods + # + # person.name_short? # => NoMethodError + def undefine_attribute_methods + generated_attribute_methods.module_eval do + instance_methods.each { |m| undef_method(m) } + end + attribute_method_matchers_cache.clear + end + + def generated_attribute_methods #:nodoc: + @generated_attribute_methods ||= Module.new { + extend Mutex_m + }.tap { |mod| include mod } + end + + protected + def instance_method_already_implemented?(method_name) #:nodoc: + generated_attribute_methods.method_defined?(method_name) + end + + private + # The methods +method_missing+ and +respond_to?+ of this module are + # invoked often in a typical rails, both of which invoke the method + # +match_attribute_method?+. The latter method iterates through an + # array doing regular expression matches, which results in a lot of + # object creations. Most of the time it returns a +nil+ match. As the + # match result is always the same given a +method_name+, this cache is + # used to alleviate the GC, which ultimately also speeds up the app + # significantly (in our case our test suite finishes 10% faster with + # this cache). + def attribute_method_matchers_cache #:nodoc: + @attribute_method_matchers_cache ||= ThreadSafe::Cache.new(initial_capacity: 4) + end + + def attribute_method_matcher(method_name) #:nodoc: + attribute_method_matchers_cache.compute_if_absent(method_name) do + # Must try to match prefixes/suffixes first, or else the matcher with no prefix/suffix + # will match every time. + matchers = attribute_method_matchers.partition(&:plain?).reverse.flatten(1) + match = nil + matchers.detect { |method| match = method.match(method_name) } + match + end + end + + # Define a method `name` in `mod` that dispatches to `send` + # using the given `extra` args. This fallbacks `define_method` + # and `send` if the given names cannot be compiled. + def define_proxy_call(include_private, mod, name, send, *extra) #:nodoc: + defn = if name =~ NAME_COMPILABLE_REGEXP + "def #{name}(*args)" + else + "define_method(:'#{name}') do |*args|" + end + + extra = (extra.map!(&:inspect) << "*args").join(", ") + + target = if send =~ CALL_COMPILABLE_REGEXP + "#{"self." unless include_private}#{send}(#{extra})" + else + "send(:'#{send}', #{extra})" + end + + mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + #{defn} + #{target} + end + RUBY + end + + class AttributeMethodMatcher #:nodoc: + attr_reader :prefix, :suffix, :method_missing_target + + AttributeMethodMatch = Struct.new(:target, :attr_name, :method_name) + + def initialize(options = {}) + @prefix, @suffix = options.fetch(:prefix, ''), options.fetch(:suffix, '') + @regex = /^(?:#{Regexp.escape(@prefix)})(.*)(?:#{Regexp.escape(@suffix)})$/ + @method_missing_target = "#{@prefix}attribute#{@suffix}" + @method_name = "#{prefix}%s#{suffix}" + end + + def match(method_name) + if @regex =~ method_name + AttributeMethodMatch.new(method_missing_target, $1, method_name) + end + end + + def method_name(attr_name) + @method_name % attr_name + end + + def plain? + prefix.empty? && suffix.empty? + end + end + end + + # Allows access to the object attributes, which are held in the hash + # returned by <tt>attributes</tt>, as though they were first-class + # methods. So a +Person+ class with a +name+ attribute can for example use + # <tt>Person#name</tt> and <tt>Person#name=</tt> and never directly use + # the attributes hash -- except for multiple assigns with + # <tt>ActiveRecord::Base#attributes=</tt>. + # + # It's also possible to instantiate related objects, so a <tt>Client</tt> + # class belonging to the +clients+ table with a +master_id+ foreign key + # can instantiate master through <tt>Client#master</tt>. + def method_missing(method, *args, &block) + if respond_to_without_attributes?(method, true) + super + else + match = match_attribute_method?(method.to_s) + match ? attribute_missing(match, *args, &block) : super + end + end + + # +attribute_missing+ is like +method_missing+, but for attributes. When + # +method_missing+ is called we check to see if there is a matching + # attribute method. If so, we tell +attribute_missing+ to dispatch the + # attribute. This method can be overloaded to customize the behavior. + def attribute_missing(match, *args, &block) + __send__(match.target, match.attr_name, *args, &block) + end + + # A +Person+ instance 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 + 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 the given method is private. + false + else + !match_attribute_method?(method.to_s).nil? + end + end + + protected + def attribute_method?(attr_name) #:nodoc: + respond_to_without_attributes?(:attributes) && 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) + match = self.class.send(:attribute_method_matcher, method_name) + match if match && attribute_method?(match.attr_name) + end + + def missing_attribute(attr_name, stack) + raise ActiveModel::MissingAttributeError, "missing attribute: #{attr_name}", stack + end + end +end |