How to use method_missing in Ruby

1.2k Views Asked by At

I have a homework problem to create a simple DSL configuration for Ruby.

The problem is in method_missing. I need to print out values of keys, but they're printing out automaticaly, not by command.

init.rb:

require_relative "/home/marie/dsl/store_application.rb"

config = Configus.config do |app|
  app.environment = :production

  app.key1 = "value1"
  app.key2 = "value2"

  app.group1 do |group1|
    group1.key3 = "value3"
    group1.key4 = "value4"
  end
end

store_application.rb:

class Configus
  class << self
    def config
      yield(self)
    end

    #    attr_accessor :environment,
    #                  :key1,
    #                  :key2,
    #                  :key3,
    #                  :key4

    def method_missing(m, args)
      puts args
    end

    def group1(&block)
      @group1 ||= Group1.new(&block)
    end
  end

  class Group1
    class << self
      def new
        unless @instance
          yield(self)
        end
        @instance ||= self
      end

      # attr_accessor :key1,
      #               :key2,
      #               :key3,
      #               :key4

      def method_missing(m, *args)
        p m, args
      end
    end
  end
end

Ruby's init.rb output:

marie@marie:~/dsl$ ruby init.rb 
production
value1
value2
:key3=
["value3"]
:key4=
["value4"]

The problem is that the values are printing automatically, I need to print them out using:

config.key1         => 'value1'
config.key2         => 'value2'
config::Group1.key3 => 'value3'
config::Group1.key4 => 'value4'
1

There are 1 best solutions below

1
Konstantin Strukov On BEST ANSWER

There are several things in your implementation that need to be fixed to match your expectations:

1) config class method returns the result of the block execution, so in your example the config variable contains Configus::Group1, not Configus as you probably expect.

2) method_missing now behaves in the very same way regardless of the method name. But it is quite clear that you expect different behavior for setters and getters.

So a naive (and dirty) fix could look like the following:

class Configus
  class << self    
    def config
      yield(self) if block_given?
      self
    end

    def method_missing(method_name, *args)
      @config_keys ||= {}

      if method_name.to_s.end_with?('=')
        @config_keys[method_name.to_s[0..-2].to_sym] = args
      elsif @config_keys.key?(method_name)
        @config_keys[method_name]
      else
        super
      end
    end

    # ...
  end

  # ...
end

(the same applies to the Group1, but I believe you got the idea of how to fix it too)

There is one more practical problem with your DSL, though: the support for nested setting is hard-coded and this makes it non-flexible. You cannot build nested hierarchies this way, for example, and to introduce new nested group you have to change the class definition (add method(s)). There are plenty of ways to fix this in Ruby, for example, we could use OpenStruct that does a lot of method_missing magic under the hood and simplifies the code a bit because of that. Dirty example:

require "singleton"

class Configus
  include Singleton

  class ParamSet < OpenStruct
    def method_missing(method_name, *args)
      # Naive, non-robust support for nested groups of settings
      if block_given?
        subgroup = self[method_name] || ParamSet.new
        yield(subgroup)
        self[method_name] = subgroup
      else
        super
      end
    end
  end

  def self.config
    yield(self.instance.config) if block_given?
    self.instance
  end

  def method_missing(method_name, *args)
    config.send(method_name, *args) || super
  end

  def config
    @config ||= ParamSet.new
  end
end

Now you can nest the settings, for example

config = Configus.config do |app|
  app.environment = :production

  app.key1 = "value1"
  app.key2 = "value2"

  app.group1 do |group1|
    group1.key3 = "value3"
    group1.key4 = "value4"
    group1.group2 do |group2|
      group2.key5 = "foo"
    end
  end
end

and then

config.key1 #=> "value1"
config.group1.key3 #=> "value3"
config.group1.group2.key5 #=> "foo"

P.S. One more thing to mention: the rule of thumb is to define the appropriate respond_to_missing? each time you play with method_missing (at least for production-grade code)...