I wanted to store arbitrary nested data structures in a jsonb column and be able to retrieve and work with that data seemlessly within a Rails application. There are several tutorials covering different aspects of this, but none felt complete to me.
For this example we'll be working with a User
model that can define surveys. Each survey consists of n
multiple choices questions, and each question can have between two and ten answer options.
In this case we use Postgresql's JSONB support to store the data, but you could also use other forms of serializing such as a text
column and Rails' default YAML serializing. JSONB is chosen in this case because it leaves open the possiblity of querying the data with SQL. Also, apparently serializing to JSON is much faster.[1]
I use the virtus gem to easily define the data attributes on each data object.
The walkthrough assumes you have a User
model.
We'll assume you already have a user model; begin by creating a rails migration that adds a jsonb column to the users table:
add_column :users, :survey_data, :jsonb, default: []
The next step is to add a line to our User
model that will serialize and unserialize the survey data to a SurveyCollection instance:
app/models/user.rb
:
class User < ActiveRecord::Base
serialize :survey_data, SurveyCollection
delegate :surveys, to: :survey_data
end
We define a SurveyCollection
model to handle the serializing and unserializing of the data. It is also likely you would add additional methods or validations here, for example you might limit the number of surveys a user can have.
SurveyCollection
model will look like this:
app/models/survey_collection.rb
:
class SurveyCollection
include Virtus.model
include ActiveModel::Model
attribute :surveys, Array[Survey]
class << self
def load(data)
self.new(data)
end
def dump(data)
data.to_json
end
end
end
Lets review this class definition. The first two lines includes the Virtus.model and ActiveModel. Virtus allows us to easily define attrtibutes of specific types with coercion. For example an attribute :age, Integer
will ensure age="13" is converted to Integer value 13.
Including ActiveModel::Model helps our class to be more compaible within the Rails framework, and adds support for ActiveRecord validations; methods such as: #valid?
and #errors
.
Line 30: attribute :questions, Array[SurveyQuestion]
is similar to the serialize line in the user model. It maps our json questions
object to an array of SurveyQuestion
instances. Virtus provides this functionality.
Finally, we define the load
and dump
class methods which are called when serializing and unserializing the data.
We still need to define our, Survey
, SurveyQuestion
and SurveyAnswerOption
classes:
app/models/survey.rb
class Survey
include Virtus.model
include ActiveModel::Model
attribute :name
attribute :questions, Array[SurveyQuestion]
validates_presence_of :name
end
app/models/survey_question.rb
:
class SurveyQuestion
include Virtus.model
include ActiveModel::Model
attribute :text, String # "What is 2 + 2?"
attribute :answer_options, Array[SurveyAnswerOption]
validates_presence_of :text
validates_length_of :answer_options, within: 2..10,
too_long: 'maximum is 10', too_short: 'minimum is 2'
end
app/models/survey_answer_option.rb
:
class SurveyAnswerOption
include Virtus.model
include ActiveModel::Model
attribute :text, String
validates_presence_of :text
end
The code is very similar to the SurveyCollection
class. We added a couple validations to ensure the question text is present, and that there are a sane number of answer options.
Now working with this data is really easy and straight forward and much like if we had used typical rails models backed by indiviudal surveys, questions, answer_options tables.
Print out all survey names:
user.surveys.each { |s| puts s.name }
Get a list of the answer option text in a question:
user.surveys[0].questions[0].answer_options.collect(&:text)
Coming soon!