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

In Part 1 of this series, we built upon the Course example, 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 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.

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.

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]