Polymorphic Attributes in Rails – Part 1
In past episodes we’ve talked about the Whole Value pattern for fully representing entity attributes. And we’ve talked about how to implement it in Rails. But what if fully representing an attribute means it might take on one of several different data types?
In today’s episode, guest chef Corey Haines joins us for part one of a two-part series on polymorphic attributes in Rails. Today, he’s going to show us how to use custom attribute types to load and store complex structured data. 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 I
In Episode #506, Justin Weiss showed how to implement Whole Values by using Rails Custom Types for two attributes of a Course type, name and duration.
I want to expand on that for the case when our data is not as simple as a base type. What if we have a more complex data structure, such as a Final Exam?
In Part 1 of this series, we'll expand Justin's work to do just this, building a more complex custom attribute based on using a hash.
Also we could have a data structure that represents different subtypes of a core type, such as different types of Final Exams.
In Part 2, we will implement this idea by supporting polymorphic types of different FinalExams, such as Multiple Choice and Essay, based on a key in the data.
Let's start with initializing a Rails app without all the front-end stuff and with Postgres as our database.
rails new catalog --skip-javascript --skip-action-mailers --skip-action-cable --database=postgresql
cd catalog
The default database config doesn't have a username/password set up, so we'll need to do that with a super-secure password.
vim config/database.yml
username: postgres
password: postgres
Now, let's create the database
bin/rails db:create
We're going to work through the examples in a script. Let's get that set up. Checking the rails version is always a great way to make sure things are hooked up properly.
vim tmp/script.rb
require './config/environment.rb'
Rails.version # =>
Our final exam will consist of two pieces of data: a list of question/answer pairs and a minimum passing score. We can represent this with a simple, albeit naive, data structure. We've got here a very simple math test with two questions. Luckily we'll only need one correct answer to pass.
final_exam = {
questions: [
{ question: "What is 2 + 2", answer: "4" },
{ question: "What is 10 + 5", answer: "15" }
],
passing_score: 1
}
final_exam # =>
Storing a hash like this is a great use for the jsonb
column type. We can create the course model with a column for the final-exam.
bin/rails g model course final_exam:jsonb
bin/rails db:migrate
Now let's make sure we can instantiate a Course. Great!
Course.new # =>
Rails happily works with jsonb
columns. But when we save it and access the attribute, Rails converts the json to a hash, whereas we want to work with it as a whole value. We can implement this in a similar fashion to Name
and Duration
from Episode 506.
course = Course.new
course.final_exam = final_exam
course.final_exam # =>
We'll start by creating a FinalExam
class to represent the data structure. This class will need to know how to serialize and deserialize to our structure for saving into our jsonb column. We'll put this in app/values
.
mkdir app/values
touch app/values/final_exam.rb
Our FinalExam will have two attributes: a questions and passing score.
class FinalExam
attr_accessor :questions, :passing_score
end
The initialize method will deserialize from the structure.
def initialize(data)
self.questions = data[:questions]
self.passing_score = data[:passing_score]
end
And we'll implement a to_hash function to serialize back into the hash.
def to_hash()
{
questions: self.questions,
passing_score: self.passing_score
}
end
end
Let's play with this for a moment. We can create a new FinalExam
and pull it back as a hash.
exam = FinalExam.new(final_exam) # =>
exam.to_hash # =>
We can now implement the FinalExamType
custom type that will handle the serializing and deserializing to json. Just like in Justin's episode, we'll subclass ActiveModel::Type::Value
.
class FinalExamType < ActiveModel::Type::Value
end
And we'll need to have cast and serialize.
def cast(value)
end
def serialize(value)
end
serialize
is fairly straightforward, as we can call .to_hash
on the value and then convert to json.
def serialize(value)
value.to_hash.to_json
end
Let's see this in action. This serializes our FinalExam
into a json string that will be stored in the database.
FinalExamType.new.serialize(exam) # =>
cast
is a bit more complicated, as for a complex structure like this, we'll want to handle three different situations where this attribute is set:
def cast(value)
end
When rails pulls it from the database, we'll receive a json string.
def cast(value)
value_hash = JSON.parse(value, symbolize_names: true)
FinalExam.new(value_hash)
end
Let's take a look at how this works in our script by passing in a pre-built json string and casting it.
json_data = "{\"questions\":[{\"question\":\"What is 2 + 2\",\"answer\":4},{\"question\":\"What is 10 + 5\",\"answer\":15}],\"passing_score\":1}"
FinalExamType.new.cast(json_data) # =>
When the attribute is set via a Rails params hash,though, we'll receive a standard hash, not a json string. old
value_hash = JSON.parse(value, symbolize_names: true)
So, we'll need to differentiate between the hash and the json string. Let's detect if we have been passed a Hash, otherwise assume a json string. new
value_hash = if value.is_a?(Hash) then value else JSON.parse(value) end
Let's take a look at how this works in our script using the hash we already have.
FinalExamType.new.cast(final_exam) # =>
And for safety, let's handle if the attribute is set with an already instantiated FinalExam
. We'll create a simple guard clause to just return the value.
return value if value.is_a?(FinalExam)
Let's take a look at how this works in our script by passing an existing FinalExam
to cast
.
exam = FinalExam.new(final_exam)
FinalExamType.new.cast(exam) # =>
As we see, implementing complex data structures is no more difficult than with simple structures, such as Name
and Duration
, that we had strings for.
And, it works the same way when setting it as an attribute in our Course model.
class Course < ActiveModel
attribute :final_exam, FinalExamType.new
end
Trying to instantiate a new Course, though, reveals a missed case in our cast: we need to handle nil
.
Course.new.final_exam # =>
Luckily, attributes allow us to set a default value to use instead of nil
.
attribute :final_exam, FinalExamType.new, default: FinalExam.new({})
We can now see this in action.
Course.new # => #>
Let's clean up this leaky abstraction a bit by asking the FinalExamType
for its empty value.
attribute :final_exam, FinalExamType.new, default: FinalExamType.empty
What is empty
, well we haven't defined it yet. Let's do that now.
def self.empty
FinalExam.new({})
end
Now when we instantiate a new Course
, it works fine and returns an empty FinalExam
.
Course.new.final_exam # => #
At this point, we have support for using a more complex whole value as our
final_exam
attribute. As we've seen, it is almost as straightforward as when basing our custom type on a simple type, such as a String.
In Part 2, we'll expand this to support returning different types of FinalExams based on what data is in the hash.
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