Monday, September 19, 2011

Mixing Ruby: Dynamic Properties

"The very things I find ugly in Ruby are what make amazing Ruby software like RSpec possible, and that Python could never have (given the current implementation)." Gary Bernhardt [1]

Reading Metaprogramming Ruby, I was inspired to create my own Ruby mixin for a pattern I've seen implemented quite often: "Dynamic Properties".  This pattern is definitely not new; a number of popular libraries very effectively use the pattern to deliver amazing functionality (and expressiveness).

What is a Dynamic Property?  Dynamic properties (or methods) are the use of undefined methods (never defined on the class) to represent model 'constructs' of that class.  Let me demonstrate:

package com.berico;

import java.util.Date;

public class WeatherObservation {

  private Date observationTime = null;

  public Date getObservationTime() {
    return observationTime;
  }

  public void setObservationTime(Date observationTime) {
     this.observationTime = observationTime;
  }	
}

Ok, we have a simple Java class.  Hell, in three edits, this could be a C# class as well.  Think about what happens when you do something like this:

public static void main(String... args){
		
  WeatherObservation wxOb = new WeatherObservation();
  wxOb.setObservationTime(new Date());

  // WTF?  This doesn't even compile!!!!		
  wxOb.setTemperature(42);
		
}

We know that this is virtually impossible in staticly-typed languages.  The "setTemperature" method does not exist and the class cannot be compiled.  In some instances (in Java), it is possible to call a method on an class defined in an external archive (JAR) that doesn't exist at runtime; this might occur if the project is compiled against a different version of a dependency than is in the classpath when the application is running.  Calling a method that doesn't exist in Java incurs the NoSuchMethodException; and your program breaks unless it is wrapped in some try-catch block (which is unlikely because you probably aren't accounting for methods "disappearing" on you).

Ruby handles things a little bit differently.  Instead of immediately raising an error, the runtime offers the class an option to handle the event of an undefined method being called on a class/instance.  If the class (or a parent class) has a function cleverly called "method_missing" defined, that method will be called with the name of the missing method and an array of arguments supplied to that method.  How you handle the unavailability of the method is completely up to you.

Many frameworks use "missing_method" to do some pretty awesome things.  Think about it, in Ruby, you can catch any method that doesn't exist as it is called on that class!  ActiveRecord, an extremely popular ORM in Ruby, uses method_missing to create "dynamic finders" [2].  The following is an example from the ActiveRecord documentation:

Person.where(:user_name => user_name, :password => password).first
Person.find_by_user_name_and_password(user_name, password)

The method "find_by_user_name_and_password" does not exist on the person object, and certainly doesn't exist on ActiveRecord::Base, the class model classes extend when using the ORM.  Instead, ActiveRecord catches the method when it goes "missing", and parses the name to determine the "intent" of the function (in this case "find" by two parameters: user_name and password).

Having liked the pattern, I've used it on a couple of my personal programming projects.  After my second implementation, I decided that it was time to figure out some way to abstract the code into something more "reusable".  In Java and C#, we would naturally create some base class (gross).  Thankfully, Ruby supports mixins, so I've created a very tiny implementation of the pattern as a mixin you can include on your classes.  I also took the pattern one step further and borrowed a technique from ActiveRecord, in which the class is "monkey patched" to permanently add the functionality (in my case the addition of a "getter" and "setter") to prevent the overhead of lookups on ancestor classes, as well as, any processing within method_missing.

Here is the source code for the DynamicProperties module/mixin:

module Berico

  # Enable the use of properties on a class
  # that are not explicitly defined within
  # the class.  These properties exist as a
  # hash on the object, which can be accessed
  # via the instance property "properties" or
  # by calling the property's name (key) on
  # the object.  Properties can also be
  # dynamically set and added to the hash by
  # invoking the "{property_name}=" method.
  module DynamicProperties

    attr_reader :properties, :configured

    # Called on the first method_missing invocation.
    # Configure the mixin using configuration
    # properties from the class including the mixin,
    # or use default configuration if those properties
    # are missing.
    # @param configuration [Object] (optional) config hash
    def configure(configuration = {})
      # Initialize the Property Bag
      @properties = {}

      # default configuration
      @_configuration = {}

      # has the config been checked?
      # since we rely on state from an initialized
      # object or class, and can't guarantee that
      # the info has been applied before including
      # this module, we need to lazy-load the functionality
      # on the first missing_method call.
      # This is the flag that will tell us whether
      # that was performed.
      @configured = true

      # Merge existing properties if the configuration
      # hash has a :properties key
      if configuration.has_key? :properties
        configuration[:properties].each do |k, value|
          key = (k.instance_of? Symbol)? k.to_s : k
          @properties.store(key, value)
        end
      end

      # If the class we are mixing
      # has supplied configuration
      # details for the mixin
      unless configuration == {}
        configuration = {} unless self.config_valid?(configuration)
      end
      # Create a parser for the property name
      @name_parser = create_name_parser(configuration)
      true
    end

    # Create a lambda that will parse the correct
    # property name based on the supplied naming strategy
    # (found in the config hash).
    # @param config [Hash] configuration of the property parser;
    #  options include a prefix, suffix, regex (or identity)
    # @return [Lambda] property name parser (default is identity)
    def create_name_parser(config)
      # Regex Matcher
      return lambda do |method_name|
          return $1 if method_name =~ config[:matcher]
        end if config.has_key? :matcher
      # Prefix Parser
      return lambda do |method_name|
          return method_name.sub(config[:prefix], "") 
             if method_name.start_with? config[:prefix]
        end if config.has_key? :prefix
      # Suffix Parser
      return lambda do |method_name|
          return method_name.chomp(config[:suffix]) 
             if method_name.end_with? config[:suffix]
        end if config.has_key? :suffix
      # Identity Parser
      lambda { |method_name| return method_name }
    end

    # Is the supplied configuration valid
    # for the DynamicProperties mixin?
    # @param config [Hash] Hash of config properties
    def config_valid?(config)
      valid_for_key? config, :prefix, String or
          valid_for_key? config, :suffix, String or
          valid_for_key? config, :matcher, Regexp
    end

    # Is the configuration valid for the given key
    # @param config_hash [Hash] Configuration
    # @param key [String or Symbol] Key to look up
    # @param class_type [Class] class the value should be
    # @return [TrueClass or FalseClass] whether the key is valid
    def valid_for_key?(config_hash, key, class_type)
      # Hash has the key
      if config_hash.has_key? key
        # Value is the right type
        config_hash[key].instance_of? class_type
      end
    end

    # Here's the magic! Every time a method
    # goes missing, we will test the method name
    # to see if it matches our requirements.
    # If the requirements are a match,
    def method_missing(name, *args)
      if not @configured
        @configured = configure
      end
      # By default, we are getting properties
      mode = :getter
      # if the method name is a symbol,
      # convert it to a string,
      # otherwise, clone the name string
      # (we're going to modify it)
      method = (name.instance_of? Symbol) ? name.to_s : name.clone
      # If this is a setter
      if method.end_with? "="
        # remove the "=" sign
        method.chomp! "="
        # change the mode to set
        mode = :setter
      end
      # get the property name
      property_name = @name_parser.call(method)
      # if the property name is null, call the
      # base object's method_missing
      if property_name.nil?
        super
      else
        # Monkey Patch the property so the next
        # call doesn't go "missing"
        self.patch_property property_name
        # if we are dealing with a getter
        if mode == :getter
          if @properties.has_key? property_name
            return @properties[property_name]
          else
            super
          end
        # else, this is a setter!
        else
          # create the property
          @properties[property_name] = args[0]
        end
      end
    end

    # Monkey patch the existing class to have
    # the property (thereby not incurring the
    # overhead of a method_missing call)
    # @param method_name [String] name of the method
    # to add to the class.
    def patch_property(method_name)
      self.class.class_eval  %Q{
         class #{self.class}
           def #{method_name}
             @properties['#{method_name}']
           end
           def #{method_name}=(value)
             @properties['#{method_name}'] = value
           end
         end }
    end

  end
end

Now you can use properties dynamically by including the mixin.  The following is a very simple example of the pattern in action:

require_relative 'dynamic_properties'
require "date"

class WeatherObservation
  include Berico::DynamicProperties

  def initialize
    @date_time = ::DateTime.now
  end

  def to_s
    output = "Weather Observation \n"
    output << "  Time: #{@date_time}\n"
    @properties.each do |key, value|
       output << "  #{key}: #{value}\n"
    end
    output
  end
end

observation = WeatherObservation.new

observation.temperature = 75
observation.dew_point = 25
observation.wind_speed = 10
observation.wind_dir = 270
observation.visibility = 10
observation.sky_con = :clear
observation.altimeter = 29.92

puts observation

Like any good Rubyist, I have a battery of RSpec tests verifying the mixin performs as advertised.  I'm hosting the project as a part of a Commons Repository for Ruby:

https://github.com/berico-rclayton/berico-ruby-common

Please let me know what you think!  I'm also interested if someone has already done the same thing (please let me know!).

Good Luck and Happy Coding.

Richard

Footnotes:
  1. Cited in: http://blog.peepcode.com/tutorials/2010/what-pythonistas-think-of-ruby
  2. http://api.rubyonrails.org/classes/ActiveRecord/Base.html

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.