One of my favorite features of ActiveRecord is the ability to provide a custom serializer to use for database-backed columns. Serialization enhances the underlying value of a column with some additional functionality, type casting, or whatever you need. The API is incredibly simple.
Here is a basic example of a JSON-serialized column:
# We're assuming the `products` table has a text column named `details`
class Product
serialize :details, coder: JSON
end
A basic Product model with a details text column.
In this example we have a column on the products table, used by the Product model, named details. Typically here you’d want to reach for a jsonb column type, but text columns work as well.
The implementation allows us to have some convenience methods on the model, so we can nicely work with JSON:
# setting some JSON data
product = Product.new
product.details = { "hello" => "world" }
product.details["hello"] # => "world"
# loading data from a model
product = Product.find(1)
product.details["hello"] # => "world"
That’s the basics. A simple text-backed column in your database, but it looks and feels like a hash, via the JSON class.
Serializing Strings into Objects
The basic JSON-style use case for serialized attributes is very handy and I use it often. But a lesser known, or perhaps lesser used, implementation of this feature isn’t to store custom data structures but rather to enhance a value with more information or an enum style attribute approach.
Here’s an example that I used a few years ago on an app that interacted heavily with some Amazon marketplace APIs. The APIs and use case don’t really matter here but the illustration is helpful to show how the serialized attributes can be used.
Let’s continue with our Product model, but add a marketplace string column:
class Product
serialize :details, coder: JSON
serialize :marketplace, coder: Marketplace
end
The marketplace column is a simple string column. But when serialized into an object, we can add some helpful convenience functionality.
A Basic Custom Serializer
Let’s break down this Marketplace class we’re using as the coder for the serializer:
class Marketplace
def initialize(key)
@key = key
end
def to_s
@key.to_s
end
def self.dump(marketplace)
marketplace.to_s
end
def self.load(raw)
new(raw)
end
end
This is about as simple an example of a serializer as we can build. A Rails attribute serializer only needs to respond to two methods: load and dump.
Marketplace.load is how we create a new Marketplace instance from the value in the Product’s marketplace column. In this case, our string can also be nil, so make sure to account for loading nil when loading data into a serializer.
Marketplace.dump is used to turn the Marketplace instance back into a string to be saved in the database. This example is extremely simple, so we’re just turning the Marketplace back into a string using to_s.
So far we’ve created a serializer that takes a raw string and turns it into an instance of the Marketplace class, with the key as the column’s value.
Leveling Up
A basic string serializer isn’t super useful. Let’s add more value to the Marketplace class. The real benefit of using a serialized attribute on top of strings is to augment the data with other useful methods and information throughout our app.
Here’s an example. First, we’ll set a Product’s marketplace to a known value:
product = Product.new
product.marketplace = "us"
Setting up a new Product instance with “us” marketplace
Then, using some new methods and configuration data in our Marketplace class, we can have additional details about the “us” marketplace we just selected:
product.marketplace.us?
# => true
product.marketplace.id
# => "ATVPDKIKX0DER"
product.marketplace.currency
# => "USD"
product.marketplace.endpoint
# => "https://sellingpartnerapi-na.amazon.com"
Enhancing the original string serializer
Here we’re using a simple string column to match up a list of Amazon marketplaces and augmenting the string with some additional data we have in a configuration file in the app. This type of extension is super useful when writing expressive code that is clear to read. Yes, we could read the config data elsewhere, but I really like this style of syntax.
Here’s the updated Marketplace class that makes it happen:
class Marketplace
def initialize(key)
@marketplace = self.class.find(key).presence || {}
end
def aws_region
@marketplace[:aws_region]
end
def currency
@marketplace[:currency]
end
def id
@marketplace[:id]
end
def endpoint
@marketplace[:url]
end
def name
@marketplace[:name]
end
def region
@marketplace[:region]
end
def to_s
@marketplace[:key]
end
def method_missing(method_name)
if match = method_name.to_s.match(/^(?<key>[a-z0-9]+)\?$/)
match[:key] == to_s
else
super
end
end
def self.all
@all ||= JSON.parse(File.read(Rails.root.join("config/data/marketplaces.json"))).map do |hash|
Hash[hash.map { |k, v| [k.to_sym, v] }]
end
end
def self.find(key)
key = key.to_s
all.detect { |hash| hash[:key] == key || hash[:id] == key }
end
def self.dump(marketplace)
marketplace.to_s
end
def self.load(raw)
new(raw)
end
end
The Marketplace serializer with additional values
And, for reference, here is some JSON configuration data that I have saved in a separate file for loading in:
[
{
"key": "us",
"name": "United States",
"url": "https://sellingpartnerapi-na.amazon.com",
"aws_region": "us-east-1",
"id": "ATVPDKIKX0DER",
"region": "North America",
"currency": "USD"
},
{
"key": "ca",
"name": "Canada",
"url": "https://sellingpartnerapi-na.amazon.com",
"aws_region": "us-east-1",
"id": "A2EUQ1WTGCTBG2",
"region": "North America",
"currency": "CAD"
}
]
I use this sort of serialized attribute all over my apps. At its simplest level, this is just a string column in a database. But with a little extra manipulation, we now have a very expressive system for enhancing our boring column attributes.
Happy serializing!
Drop me a line any time.