Search code examples
ruby-on-railsrubymetaprogrammingmixins

How would I implement my own Rails-style validates() method in Ruby?


I'm trying to understand some Ruby metaprogramming concepts.

I think I understand classes, objects, and metaclasses. Unfortunately, I'm very unclear on exactly what happens with included Modules with respect to their instance/'class' variables.

Here's a contrived question whose solution will answer my questions:

Suppose I'm writing my own crappy Rails "validates" method, but I want it to come from a mixed-in module, not a base class:

module MyMixin
  # Somehow validates_wordiness_of() is defined/injected here.

  def valid?
    # Run through all of the fields enumerated in a class that uses
    # "validate_wordiness_of" and make sure they .match(/\A\w+\z/)
  end
end

class MyClass
  include MyMixin

  # Now I can call this method in my class definition and it will
  # validate the word-ness of my string fields.
  validate_wordiness_of :string_field1, :string_field2, :string_field3

  # Insert rest of class here...
end

# This should work.
MyMixin.new.valid?

Ok, so how would you store that list of fields from the validate_wordiness_of invocation (in MyClass) in such a way that it can be used in the valid? method (from MyMixin)?

Or am I coming at this all wrong? Any info would be super appreciated!


Solution

  • So here are two alternative ways of doing it:

    With "direct" access

    module MyMixin
    
      def self.included(base)
        base.extend(ClassMethods)
      end
    
      def wordy?(value)
        value.length > 2
      end
      module ClassMethods
        def validates_wordiness_of(*attrs)
          define_method(:valid?) do
            attrs.all? do |attr|
              wordy?(send(attr))
            end
          end
        end
      end
    end
    
    class MyClass
      include MyMixin
    
      validates_wordiness_of :foo, :bar
    
      def foo
        "a"
      end
    
      def bar
        "asrtioenarst"
      end
    end
    
    puts MyClass.new.valid?
    

    The downside to this approach is that several consecutive calls to validates_wordiness_of will overwrite each other.

    So you can't do this:

    validates_wordiness_of :foo
    validates_wordiness_of :bar
    

    Saving validated attribute names in the class

    You could also do this:

    require 'set'
    module MyMixin
      def self.included(base)
        base.extend(ClassMethods)
      end
    
      module Validation
        def valid?
          self.class.wordy_attributes.all? do |attr|
            wordy?(self.send(attr))
          end
        end
    
        def wordy?(value)
          value.length > 2
        end
      end
    
      module ClassMethods
        def wordy_attributes
          @wordy_attributes ||= Set.new
        end
    
        def validates_wordiness_of(*attrs)
          include(Validation) unless validation_included?
          wordy_attributes.merge(attrs)
        end
    
        def validation_included?
          ancestors.include?(Validation)
        end
      end
    end
    
    class MyClass
      include MyMixin
    
      validates_wordiness_of :foo, :bar
    
      def foo
        "aastrarst"
      end
    
      def bar
        "asrtioenarst"
      end
    end
    
    MyClass.new.valid?
    # => true
    

    I chose to make the valid? method unavailable until you actually add a validation. This may be unwise. You could probably just have it return true if there are no validations.

    This solution will quickly become unwieldy if you introduce other kinds of validations. In that case I would start wrapping validations in validator objects.