In Progress
Unit 1, Lesson 1
In Progress

Polymorphic Attributes in Rails – Part 2

In the previous episode, guest chef Corey Haines joined us to talk about representing complex structured data with custom attribute types in Rails. Today, he returns to kick it up a notch, and show us how to support attributes that might take on one of several different data types. Enjoy!

Corey has also generously offered a 45% discount on his book Understanding The Four Rules of Simple Design to subscribers—see details after the episode script!

Video transcript & code

Complex and Polymorphic Whole Value Attributes in Rails Part II

Course Type In Part 1 of this series, we built upon the Course example, FinalExam expanding our understanding of custom types by implementing a FinalExam type that is backed by a more complex data structure.

In this part, we'll be pushing a bit further to support a Multiple Types style of polymorphic attribute that supports different types of Final Exams. We've built a multi-question exam, but we can imagine other kinds with different sets of data.

Such as an essay question


essay_exam_data = {
  question: "Explain the origin of the great spot on Jupiter?"
}

or a multiple choice exam, similar to our original FinalExam, but with more information per question.


multiple_choice_exam_data = {
  questions: [
    { question: "What is 2 + 2", answers: ["4", "10", "8"], answer: "4" },
    { question: "What is 10 + 5", answers: ["20", "15", "5"], answer: "15" }
  ],
  passing_score: 1
}

When working with different types of related objects using Rails ActiveRelation functionality, we would implement something like this with polymorphic relations. However, that requires us to expand our final_exam field into separate tables, which feels like overkill for something like this.

Happily, we can implement something very similar within the realm of custom types, a type of polymorphic attribute. We can implement this by making the decision in our FinalExamType#cast and returning different types of objects. Let's see how we would do this. We currently only have a single type: FinalExam.


  def cast(value)
    return value if value.is_a?(FinalExam)

    value_hash = if value.is_a?(Hash) then value else JSON.parse(value) end
    FinalExam.new(value_hash)
  end

We'll start with making some basic changes to FinalExam to set ourselves up for different types of exams. Let's change our current one into a QuestionSet in a FinalExams namespace.


mkdir app/values/final_exams
vim app/values/final_exams/question_set.rb

This will be the same code from our old FinalExam, just renamed.


class FinalExams::QuestionSet
  attr_accessor :questions, :passing_score

  def initialize(data)
    self.questions = data[:questions]
    self.passing_score = data[:passing_score]
  end

  def to_hash()
  {
    questions: self.questions,
    passing_score: self.passing_score
  }
  end
end

Let's make sure it works by testing it out in our script.


exam = FinalExams::QuestionSet.new(final_exam)  # => #"What is 2 + 2", :answer=>4}, {:question=>"What is 10 + 5", :answer=>15}], @passing_score=1>

Let's adjust our existing FinalExam code to use the QuestionSet exam type. For now, this is just changing FinalExam to FinalExams::QuestionSet.


  def cast(value)
    return value if value.is_a?(FinalExams::QuestionSet)

    value_hash = if value.is_a?(Hash) then value else JSON.parse(value) end
    FinalExams::QuestionSet.new(value_hash)
  end

  def self.empty
    FinalExams::QuestionSet.new({})
  end

And, of course, we can see it working the same way in our script.


Course.new.final_exam
# =>

exam = FinalExams::QuestionSet.new(hash_data)
FinalExamType.new.cast(exam)
# =>

Now that we've refactored our existing code to use this new format, we can look at how best to implement our multiple types. We'll start by adding the type to our data structure:


{
  exam_type: ""
}

We'll want some sort of key there to differentiate which type of exam we have. We'll take the simplest approach and just use the class name.


{
  exam_type: "FinalExams::QuestionSet"
}

We will need to have our to_hash add this key when serializing.


  def to_hash()
  {
    exam_type: self.class.name,
    questions: self.questions,
    passing_score: self.passing_score
  }
  end

With one last refactoring, we'll be ready for our new types. Let's have FinalExamType use this exam_type to figure out which type to instantiate.


    value_hash = if value.is_a?(Hash) then value else JSON.parse(value) end

    case value_hash[:exam_type] 
    when "FinalExams::QuestionSet"
      FinalExams::QuestionSet.new(value_hash)
    end

If we get a structure for an exam type we don't know about, I think it would be nice to be alerted pretty quickly. Let's raise an exception.


    case value_hash[:exam_type] 
    when "FinalExams::QuestionSet"
      FinalExams::QuestionSet.new(value_hash)
    else
      raise "Unknown Exam Type #{value_hash[:exam_type]}"
    end

Now we have everything set up nicely. Let's create our other types.

Let's add the exam type keys to the hashes in our test script


  exam_type: "FinalExams::MultipleChoice"

  exam_type: "FinalExams::Essay"

Multiple choice looks very similar, just parsing out the data from the hash.


class FinalExams::MultipleChoice
  attr_accessor :questions, :passing_score

  def initialize(data)
    self.questions = data[:questions]
    self.passing_score = data[:passing_score]
  end

  def to_hash()
    {
      exam_type: self.class.name,
      questions: self.questions,
      passing_score: self.passing_score
    }
  end
end

Essay exams are similar, as well. It is always just about pulling the correct data out.


class FinalExams::Essay
  attr_accessor :question

  def initialize(data)
    self.question = data[:question]
  end

  def to_hash()
    {
      exam_type: self.class.name,
      question: self.question
    }
  end
end

We can now use these in our FinalExamType by adding the exam_type values to our constructor choice.


    case value_hash[:exam_type] 
    when "FinalExams::QuestionSet"
      FinalExams::QuestionSet.new(value_hash)
    when "FinalExams::Essay"
      FinalExams::Essay.new(value_hash)
    when "FinalExams::MultipleChoice"
      FinalExams::MultipleChoice.new(value_hash)
    else
      raise "Unknown Exam Type #{value_hash[:exam_type]}"
    end

Yuk! Having these strings there is a classic example of knowledge duplication. Let's eliminate this duplication by adding a Key to our types and using them instead of the strings.

We'll need to have this set up in our types


  Key = self.name

      exam_type: Key,

and then update our case statement in FinalExamType to use it


    case value_hash[:exam_type] 
    when FinalExams::QuestionSet::Key
      FinalExams::QuestionSet.new(value_hash)
    when FinalExams::Essay::Key
      FinalExams::Essay.new(value_hash)
    when FinalExams::MultipleChoice::Key
      FinalExams::MultipleChoice.new(value_hash)
    else
      raise "Unknown Exam Type #{value_hash[:exam_type]}"
    end

This is pretty great. Let's make sure that we can cast appropriately.


FinalExamType.new.cast(question_set_exam_data)
# =>
FinalExamType.new.cast(essay_exam_data)
# =>
FinalExamType.new.cast(multiple_choice_exam_data)
# =>

At this point, we now have multiple types of exams supported based on the exam_type. Our only conditional is in the FinalExamType where we figure out which type to use. Each type is responsible for handling its own specific data, as well as any functionality that is specific for that exam type.

Multiple Types Adding a new type is fairly straightforward, almost entirely abiding by the open-closed principle; we only need to adjust FinalExamType to add a new one. We could even eliminate this by implementing a form of constructor lookup based on the exam_type. But, that is maybe for another day.

Multiple Types As you can see, like with most things in Rails, working with more complex data structures as whole value activemodel attributes is pretty straight-forward. We can even take advantage of the object-oriented nature of Ruby to make this attribute a polymorphic type. Sometimes we do want separate tables for these different types, but having a data structure as an attribute like this is great for getting started when you aren't quite sure whether you need that complexity.

Until next time, thanks for watching and happy hacking.

[su_box title="Corey Haines Book Offer" box_color="#283037"]

Bonus! For a limited time, Corey is offering his book Understanding the Four Rules of Simple Design to RubyTapas subscribers at a 45% discount.

Click here to get Understanding the Four Rules of Simple Design.

[/su_box]

Responses