How I Used ActiveRecord#serialize with a Custom Data Type
Zachary Porter, Former Senior Developer
Article Category:
Posted on
In case you're unfamiliar, ActiveRecord#serialize
is a method that allows an attribute to be saved in the database and later retrieved as an object. The common example given involves storing a user's preferences like so:
# Serialize a preferences attribute.
class User < ActiveRecord::Base
serialize :preferences
end
user = User.new
user.preferences = { send_spam: false, send_deals: true }
user.save # => preferences saved as YAML in a text field, later retrieved as a Hash
You can even enforce the type of the object stored in the column:
class User < ActiveRecord::Base
serialize :preferences, Hash
end
user = User.new
user.preferences = 'lolwut'
user.save # => raises ActiveRecord::SerializationTypeMismatch
Pretty neat stuff, but it requires the database column to be a text field. What if I want the database column to be a different type? Well, I recently dug into the Rails source code to find out how serialize
works and what's required to use it with a custom data type.
I had a feature that allowed a user to manage a time duration in minutes and seconds. This time duration would be stored as the total amount of seconds in the database so that it could easily be converted to other units of time. It would be ideal if that seconds field had an integer type. After several iterations, I settled on a Duration
class that would handle the conversions. The next step was to figure out how to store and retrieve an instance of Duration
in the database.
Upon inspection of the serialize
method within the Rails source, I discovered this interesting conditional (copied here for convenience):
coder = if [:load, :dump].all? { |x| class_name.respond_to?(x) }
class_name
else
Coders::YAMLColumn.new(class_name)
end
So, if the provided class_name
responds to load
and dump
, then the serialize
method will use that class. Otherwise, it will fallback to the Coders::YAMLColumn
class to handle the loading and saving to the database. I took a look at how the load
and dump
methods were implemented on the Coders::YAMLColumn
class:
def dump(obj)
return if obj.nil?
unless obj.is_a?(object_class)
raise SerializationTypeMismatch,
"Attribute was supposed to be a #{object_class}, but was a #{obj.class}. -- #{obj.inspect}"
end
YAML.dump obj
end
def load(yaml)
return object_class.new if object_class != Object && yaml.nil?
return yaml unless yaml.is_a?(String) && yaml =~ /^---/
obj = YAML.load(yaml)
unless obj.is_a?(object_class) || obj.nil?
raise SerializationTypeMismatch,
"Attribute was supposed to be a #{object_class}, but was a #{obj.class}"
end
obj ||= object_class.new if object_class != Object
obj
end
To briefly summarize the above code, the dump
method takes an object and returns the value to be stored in the database, and the load
method takes the database value and returns an instance of the specified class.
I took everything that I learned about the serialize
method and applied it in a custom Duration
data type:
class Duration
# Used for `serialize` method in ActiveRecord
class << self
def load(duration)
self.new(duration || 0)
end
def dump(obj)
unless obj.is_a?(self)
raise ::ActiveRecord::SerializationTypeMismatch,
"Attribute was supposed to be a #{self}, but was a #{obj.class}. -- #{obj.inspect}"
end
obj.length
end
end
attr_accessor :minutes, :seconds
def initialize(duration)
@minutes = duration / 60
@seconds = duration % 60
end
def length
(minutes.to_i * 60) + seconds.to_i
end
end
Then, in my ActiveRecord
model, I added the following snippet of code:
serialize :duration_field, Duration
delegate :minutes, :minutes=, :seconds, :seconds=, to: :duration_field
And there we have it -- a lightweight class that's able to take advantage of a method provided by the Rails framework.
What do you think? Are you using the serialize
method with your custom data types? Let me know in the comments below.