loading...

Ruby Value Objects for Sets

databasesponge profile image MetaDave πŸ‡ͺπŸ‡Ί ・4 min read

TL;DR

Sometimes a set of value objects has its own attributes in addition to the attributes of the individual values themselves.

Background

When we have a single value and we want to infer behaviour from it, for example when we have country codes and we want to be able to read the continent name, the currencies, or the key of the national anthem, then a value object is probably what we're looking for.

And when we have a set of such single values, such as a list of countries to be visited in a trip, then we also want to send messages to that list of countries.

At the simple end, we might have #count, or #uniq_count if countries can be present more than once, currencies (expecting a list of the currencies for all of the countries in the list to be returned), etc..

Quite often there are issues of compatibility or completeness involved in a set, and the code for detecting these issues needs a home.

That home is probably a value object.

class Countries
  def initialize(*countries)
    @countries = Array.wrap(countries)
  end

  delegate :count,
           :any?,
           :empty?,
           :entries,
           to: :countries

  def currencies
    countries.map(&:currencies)
  end

  def uniq_count
    countries.uniq.count
  end

  def compatible?
    etc
  end

  def closed_loop?
    countries.first == countries.last
  end
end

A bibliographic example

Consider an example: a code list that we at Consonance use a lot, the Thema subject categories for book publishing. A publisher assigns multiple codes to a book to inform third parties, such as sales agents, other publishers, distributors, retailers, and ultimately the potential buyer, what it is about and who might be interested in it.

You can see by browsing the list that there is a huge range in the subjects coding, reflecting that some books are intended for professional or academic practitioners, for children, for people with a general interest in pets, and there are also codes for place, time, and special themes (birthdays, seasonal holidays etc).

Many of these can be combined for a single book, and we expect all of them to be individually valid of course.

But some further questions can arise when you consider all of the codes assigned to a book:

  • Are codes being used which suggest that others should also be present, and if so are those others actually present?
    • Do children's books have an age range?
    • Do law books contain a geographical restriction? In general every professional law book relates to a particular geographical region.
    • Is a book about archaeology also coded for both time and place?
  • Are there codes being used which are possibly not compatible with each other?
    • Is a book about a scholarly subject also coded as being of interest to children under five years of age?
    • Is a book coded for general interest also coded for professional interest?

So individual codes can each be valid, while the combination of them can be problematic in a number of ways.

The important point here is that these complete? and valid? attributes are of the set of codes, not of the individual codes themselves, and might be implemented as:

class ThemaSet
  def initialize(*set)
    @set = set
  end

  delegate :any?, to: :set

  def errors
    errors_of_incompatibility + errors_of_incompleteness
  end

  private def childrens?
    any?(&:childrens?)
  end

  private def professional_or_scholarly?
    any?(&:professional_or_scholarly?)
  end

  private def historical?
    any(&:historical?)
  end

  private def child_incompatible?
    childrens? && (professional_or_scholarly? || adult_themed?)
  end

  private def missing_time?
    !timed? && historical?
  end

  private def missing_place?
    !placed? && (historical? || legal?)
  end

  private def errors_of_incompatibility
    [].tap do |errors|
      errors << "codes incompatible with child coding are present" if child_incompatible?
    end
  end

  private def errors_of_incompleteness
    [].tap do |errors|
      errors << "codes should be accompanied by time coding"  if missing_time?      
      errors << "codes should be accompanied by place coding" if missing_place?
    end
  end

Back to the real world

So the pattern there is reasonably clear, I think.

The set-value object examines the attributes of the individual value objects in order to produce characterisations of the set as a whole.

Although the example given was quite specific to a business area, this pattern of complete? vs incomplete?, and compatible? vs incompatible?, leading to attributes of valid? and a list of errors is a common one.

And this makes it amenable to the use of the ValidValidator described here

In many cases the individual objects will not be plain value objects, but might be derived from an ActiveRecord-backed has_many association. But if the set of objects has its own attributes, then that is still a candidate for this technique.

class Book
  has_many :thema_codes

  def thema_set
    @_thema_set ||= ThemaSet.new(thema_codes)
  end

  delegate :valid?,
           :errors,
           to:     :thema_set,
           prefix: true
end

Further functionality

A variation on this can be used to answer the question, β€œwhat is the effect of a particular change to a set?”.

Given a current set and a candidate member of it, you can compare the original against the original + candidate-value, and ask of it "have I changed a property of the set by making this change, such as introducing (or solving) any errors?"

And the implementation of that is reasonably clear:

class SetComparator
  def initialize(set_object, new_value)
    @original_set = set_object
    @new_set      = set_object.class.new(set_object.entries << new_value)
  end

  def errors_added
    new_set.errors - original_set.errors
  end

  def errors_removed
    original_set.errors - new_set.errors
  end
end

Discussion

pic
Editor guide