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.