Creating Ruby builder object with re-usable code

Viewed 83

I'm working to create a few Ruby builder objects, and thinking on how I could reuse some of Ruby's magic to reduce the logic of the builder to a single class/module. It's been ~10 years since my last dance with the language, so a bit rusty.

For example, I have this builder:

class Person
  PROPERTIES = [:name, :age]
  attr_accessor(*PROPERTIES)

  def initialize(**kwargs)
    kwargs.each do |k, v|
      self.send("#{k}=", v) if self.respond_to?(k)
    end
  end

  def build
    output = {}
    PROPERTIES.each do |prop|
      if self.respond_to?(prop) and !self.send(prop).nil?
        value = self.send(prop)
        # if value itself is a builder, evalute it
        output[prop] = value.respond_to?(:build) ? value.build : value
      end
    end

    output
  end
  
  def method_missing(m, *args, &block)
    if m.to_s.start_with?("set_")
      mm = m.to_s.gsub("set_", "")
      if PROPERTIES.include?(mm.to_sym)
        self.send("#{mm}=", *args)
        return self
      end
    end
  end
end

Which can be used like so:

Person.new(name: "Joe").set_age(30).build
# => {name: "Joe", age: 30}

I would like to be able to refactor everything to a class and/or module so that I could create multiple such builders that'll only need to define attributes and inherit or include the rest (and possibly extend each other).

class BuilderBase
  # define all/most relevant methods here for initialization,
  # builder attributes and  object construction
end

module BuilderHelper
  # possibly throw some of the methods here for better scope access
end

class Person < BuilderBase
  include BuilderHelper
  
  PROPERTIES = [:name, :age, :email, :address]
  attr_accessor(*PROPERTIES)
end

# Person.new(name: "Joe").set_age(30).set_email("joe@mail.com").set_address("NYC").build

class Server < BuilderBase
  include BuilderHelper

  PROPERTIES = [:cpu, :memory, :disk_space]
  attr_accessor(*PROPERTIES)
end

# Server.new.set_cpu("i9").set_memory("32GB").set_disk_space("1TB").build

I've been able to get this far:

class BuilderBase
  def initialize(**kwargs)
    kwargs.each do |k, v|
      self.send("#{k}=", v) if self.respond_to?(k)
    end
  end
end

class Person < BuilderBase
  PROPERTIES = [:name, :age]
  attr_accessor(*PROPERTIES)

  def build
    ...
  end
  
  def method_missing(m, *args, &block)
    ...
  end
end

Trying to extract method_missing and build into the base class or a module keeps throwing an error at me saying something like:

NameError: uninitialized constant BuilderHelper::PROPERTIES

OR

NameError: uninitialized constant BuilderBase::PROPERTIES

Essentially the neither the parent class nor the mixin are able to access the child class' attributes. For the parent this makes sense, but not sure why the mixin can't read the values inside the class it was included into. This being Ruby I'm sure there's some magical way to do this that I have missed.

Help appreciated - thanks!

3 Answers

I reduced your sample to the required parts and came up with:

module Mixin
  def say_mixin
    puts "Mixin: Value defined in #{self.class::VALUE}"
  end
end

class Parent
  def say_parent
    puts "Parent: Value defined in #{self.class::VALUE}"
  end
end

class Child < Parent
  include Mixin

  VALUE = "CHILD"
end


child = Child.new
child.say_mixin
child.say_parent

This is how you could access a CONSTANT that lives in the child/including class from the parent/included class.

But I don't see why you want to have this whole Builder thing in the first place. Would an OpenStruct not work for your case?

Interesting question. As mentioned by @Pascal, an OpenStruct might already do what you're looking for.

Still, it might be more concise to explicitly define the setter methods. It might also be clearer to replace the PROPERTIES constants by methods calls. And since I'd expect a build method to return a complete object and not just a Hash, I renamed it to to_h:

class BuilderBase
  def self.properties(*ps)
    ps.each do |property|
      attr_reader property
      define_method :"set_#{property}" do |value|
        instance_variable_set(:"@#{property}", value)
        @hash[property] = value
        self
      end
    end
  end

  def initialize(**kwargs)
    @hash = {}
    kwargs.each do |k, v|
      self.send("set_#{k}", v) if self.respond_to?(k)
    end
  end

  def to_h
    @hash
  end
end

class Person < BuilderBase
  properties :name, :age, :email, :address
end

p Person.new(name: "Joe").set_age(30).set_email("joe@mail.com").set_address("NYC").to_h
# {:name=>"Joe", :age=>30, :email=>"joe@mail.com", :address=>"NYC"}

class Server < BuilderBase
  properties :cpu, :memory, :disk_space
end

p Server.new.set_cpu("i9").set_memory("32GB").set_disk_space("1TB").to_h
# {:cpu=>"i9", :memory=>"32GB", :disk_space=>"1TB"}

I think no need to declare PROPERTIES, we can create a general builder like this:

class Builder
  attr_reader :build

  def initialize(clazz)
    @build = clazz.new
  end

  def self.build(clazz, &block)
    builder = Builder.new(clazz)
    builder.instance_eval(&block)
    builder.build
  end

  def set(attr, val)
    @build.send("#{attr}=", val)
    self
  end

  def method_missing(m, *args, &block)
    if @build.respond_to?("#{m}=")
      set(m, *args)
    else
      @build.send("#{m}", *args, &block)
    end
    self
  end

  def respond_to_missing?(method_name, include_private = false)
    @build.respond_to?(method_name) || super
  end
end

Using

class Test
  attr_accessor :x, :y, :z
  attr_reader :w, :u, :v

  def set_w(val)
    @w = val&.even? ? val : 0
  end

  def add_u(val)
    @u = val if val&.odd?
  end
end

test1 = Builder.build(Test) {
  x 1
  y 2
  z 3
} # <Test:0x000055b6b0fb2888 @x=1, @y=2, @z=3>

test2 = Builder.new(Test).set(:x, 1988).set_w(6).add_u(2).build
# <Test:0x000055b6b0fb23b0 @x=1988, @w=6> 
Related