rubydslmethod-missing

How to use method_missing in Ruby


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'

Solution

  • 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)...