mbigras mbigras - 4 years ago 135
Ruby Question

Access attributes on objects inside ruby objects

I'm building a

LineItemGenerator
object whose purpose is to generate an array of attribute values given the desired attributes.

The problem is the given object has objects as attributes. So the "attributes" given are really "nested attributes".

The goal is to access the requested nested attributes from
item
, in this case
item.name
and
item.style.name
by creating some input data structure and using some algorithm.

Currently, I'm representing my "nested attributes" input data structure as an array of arrays,
nested_attributes


My algorithm that's doing the heavy lifting is called
#generate
.

It takes the original
item
and the
nested_attributes
. Next, it maps over the
nested_attributes
, reducing each
nested_attribute
into an "attribute" by
send
ing messages to the original
item
on each iteration.

class Style
attr_reader :name
def initialize name:
@name = name
end
end

class Item
attr_reader :name, :style
def initialize name:, style:
@name = name
@style = style
end
end

class LineItemGenerator
def generate item:, nested_attributes:
nested_attributes.map do |nested_attribute|
nested_attribute.reduce(item) do |obj, attribute| # <-- algorithm using #reduce to burrow in
obj.send(attribute)
end
end
end
end


require 'minitest/autorun'

class SomeTest < Minitest::Test
def test_it_returns_the_right_line_item
style = Style.new name: 'cool'
item = Item.new name: 'pants', style: style

# input data structure is array or arrays
nested_attributes = [[:name], [:style, :name]]
input = { item: item, nested_attributes: nested_attributes}
output = LineItemGenerator.new.generate input
assert_equal ['pants', 'cool'], output
end
end


I'm curious about new ways to implement my input data structure and algorithm that are more declarative and expressive. Both sections of interest are called out in the comments above.

It feels weird to use
#inject
because I'm really just trying to chain together a variable number of
send
calls. For example:

item = Item.new name: 'pants', style: Style.new(name: 'cool')
p item.send(:style).send(:name) #=> "cool"


Is there some Enumerable method that would be a better choice in this case? Is there a better choice for my input data structure?

Answer Source

This smells more like a software design problem to me, so this is how I would approach it from a design perspective.

  • Reason from the perspective of one component at a time.
  • Separate what we're trying to communicate from how it will be implemented

The LineItemGenerator's job is to generate an array of attribute values for an item given the desired attributes.

Based on that, a LineItemGenerator:

  • takes an item with attributes
  • implements generate_attribute_values given a list of desired attributes

This might look like:

LineItemGenerator.new(@item).generate_attribute_values(:name, :style)

I'd remove generate as it doesn't seem to be the right word here. We're just retrieving and filtering existing values, not creating a new attribute value object.

LineItemGenerator.new(@item).attribute_values(:name, :style)

At this point, I consider what an Item should expose to our LineItemGenerator.

  • Items have attributes
  • Attributes have values, which implies they should have names as well.

Given that understanding, I can implement LineItemGenerator as:

class LineItemGenerator
  def initialize(item)
    @item = item
  end

  def attribute_values(*attribute_names)
    @item.attributes.select { |attribute| attribute_names.include?(attribute.name) }.map(&:value)
  end
end

At this point, there are two contracts which need to be fulfilled:

  1. Item needs to implement #attributes
  2. item.attributes needs to return a set of objects which respond to #name and #value

Now, let's think from an item's perspective. - An item has many attributes (eg. name and style). - The relevant attribute values may be defined on the Item object or be delegated to other objects.

Contract 1 is trivial to fulfil:

class Item
  attr_reader :attributes
end

Contract 2 is a bit more flexible as it can be fulfilled on either Item or the individual attribute classes. I'd implement it on Item if an Attribute is not a first class concern in the application.

class Item
  attr_reader :attributes

  Attribute = Struct.new(:name, :value)

  def initialize(name:, style:)
    @attributes = [
      Attribute.new(name: :name, value: name),
      Attribute.new(name: :style, value: style) 
    ]
  end
end

If some other part of the system needs to interact with an Attribute as a first class concern:

# TODO: DRY up using inheritance or modules
class Style
  attr_reader :value
  def initialize value:
    @value = value
  end

  def name
    :style
  end
end

class ItemName
  attr_reader :value
  def initialize value:
    @value = value
  end

  def name
    :name
  end
end

class Item
  attr_reader :name, :style, :attributes
  def initialize item_name:, style:
    @name = item_name
    @style = style

    @attributes = [@name, @style]
  end
end
Recommended from our users: Dynamic Network Monitoring from WhatsUp Gold from IPSwitch. Free Download