In Progress
Unit 1, Lesson 21
In Progress

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

Course Type 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.

FinalExam 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.

Multiple Types 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  # => #

FinalExam 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.

Multiple Types 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