Value objects in Ruby: Serializing your custom objects with ActiveRecord

Value objects in Ruby: Serializing your custom objects with ActiveRecord

In the last post I discussed the value in moving beyond Ruby's built-in data types like Strings, Arrays, and Hashes in favor of using custom data types (or value objects). There are many benefits in designing a system with custom types, one of the greatest being that your intent is the code itself instead of in documentation or a wiki. Check out part 1 if you haven't had a chance yet. This post will show you how you can use your custom data types with ActiveRecord.

Introduction

Most web applications we work on require storing data in a database. Like many Rails shops our default database of choice at Grok is Postgres. We can store the vast majority of data using its text types, numeric types, dates and times, and booleans. Postgres also provides support for collections with arrays and hstore as well as more specific types like UUIDs and IP addresses, allowing us to store that data in a more structured way instead of as text. This is how Postgres describes using the Network Address Types:

It is better to use these types instead of plain text types to store network addresses, because these types offer input error checking and specialized operators and functions.

So, we get database-level validation simply by using a data type designed for the data we're going to store in it anyway, making it impossible to store the strings like "coffee is delicious" in a column designed for just IP addresses. Brilliant!

But what if the database doesn't have a specific type for the data we need to store? We have ActiveRecord::Validations, of course! Now, how do we validate data if someone opens a database console and inserts records, or someone needs to work quickly and calls model.save(validate: false)? More recently people have been using constraints to push validations down to the database level, which is very intriguing but seems like it may be duplicating work.

You'd still want application-level validations to avoid a round-trip to the database just for validations. And keeping things like that in sync can be a nightmare. (Please let me know if you have a solution to that problem!) Though they aren't the preferred way to interact with the database, adding records using the database console or calling model.save(validate: false) aren't unheard of or too far out of the ordinary. To ensure we're working with known data we need something that fits in the middle of ActiveRecord::Validations and database-level validations. Enter ActiveRecord::serialize.

ActiveRecord#serialize with built in Ruby classes

To fill this need ActiveRecord offers the serialize class macro. For example, say you have a User class and the users table has a text column, for preferences. The intent with the preferences column is to store a collection of key-value pairs for a each user's preferences. Instead of manually parsing the stored text using String methods, ActiveRecord will coerce it into a Hash for you. Granted, in Postgres we would normally use hstore for this but there are other reasons to use serialize in this way, such as if you're using MySQL. Here's what the model would look like, just a one-line change:

class User < ActiveRecord::Base
  serialize :preferences, Hash
end

Now when we instantiate a new user and call preferences on that user, we will get back a hash. It also works for assignment. Let's take a look at what this looks like in the console:

$ bin/rails c
Loading development environment (Rails 4.2.3)
irb(main):001:0> user = User.new
=> #<User id: nil, name: nil, email: nil, preferences: {}, created_at: nil, updated_at: nil>
irb(main):002:0> user.preferences = { language: "ruby", city: "San Antonio" }
=> {:language=>"ruby", :city=>"San Antonio"}
irb(main):003:0> user.save
   (0.5ms)  BEGIN
  SQL (5.8ms)  INSERT INTO "users" ("preferences", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id"  [["preferences", "---\n:language: ruby\n:city: San Antonio\n"], ["created_at", "2015-08-17 23:10:40.429329"], ["updated_at", "2015-08-17 23:10:40.429329"]]
   (62.6ms)  COMMIT
=> true
irb(main):003:0> exit

As demonstrated above, to set a user's preferences, we use a Hash. ActiveRecord also serializes the text into a Hash when we pull the record out of the database.

That's the basics around ActiveRecord#serialize, and there are pretty big wins with it. The biggest wins come, though, when we write our own serializers using value objects.

Using a custom EmailAddress class with ActiveRecord#serialize

In the last post we created a basic EmailAddress class that included some basic validation and comparison. Here's the same class again after some refactoring:

require "forwardable"

class EmailAddress
  include Comparable
  extend Forwardable

  def initialize(string)
    if string =~ /\A\z|@/
      @raw_email_address = string.downcase.strip
    else
      raise ArgumentError, "email address must have an '@'"
    end
  end

  delegate [:to_s, :<=>] => :raw_email_address

  protected

  attr_reader :raw_email_address
end

I'm leaving out the tests for the sake of simplicity but you can check them out in the GitHub repo.

An EmailAddress gets initialized with a String, performs some basic validation (the string can either be blank or must contain an "@"), and can be compared against other EmailAddresses and Strings. ActiveRecord requires 2 class methods to serialize an attribute with our custom data type: self.load and self.dump. .load takes the value as it will be stored in the database and returns an object of the type. .dump takes an object of the type and returns the value to be stored in the database. Let's add those methods to our EmailAddress class:

require "forwardable"

class EmailAddress
  # ...

  def self.load(raw_string)
    new(raw_string || "")
  end

  def self.dump(email_address)
    if !email_address.empty?
      email_address.to_s
    end
  end

  # ...
end

I'm using the || operator in .load to handle the case when the stored value is nil or when calling User.new (which will also be nil). .dump is also handling nil checks for us: only store non-empty values or nil. As far as email addresses are concerned, this is the only place we need to account for nil in our system — we'll always have a valid EmailAddress type.

Using our custom class is the same as using the built-in, all we need to do is require it:

require "email_address"

class User < ActiveRecord::Base
  serialize :preferences, Hash
  serialize :email, EmailAddress
end

Now let's see how this works in the console:

$ bin/rails c
Loading development environment (Rails 4.2.3)
irb(main):001:0> User.first
  User Load (1.3ms)  SELECT  "users".* FROM "users"  ORDER BY "users"."id" ASC LIMIT 1
=> #<User id: 1, name: nil, email: #<EmailAddress:0x007f8edfe4de80 @raw_email_address="">, preferences: {:language=>"ruby", :city=>"San Antonio"}, created_at: "2015-08-17 23:10:40", updated_at: "2015-08-19 19:14:52">
irb(main):002:0> user = User.new
=> #<User id: nil, name: nil, email: #<EmailAddress:0x007fefa4e551f0 @raw_email_address="">, preferences: {}, created_at: nil, updated_at: nil>
irb(main):003:0> user.email = "hello@example.com"
=> "hello@example.com"
irb(main):004:0> user
=> #<User id: nil, name: nil, email: #<EmailAddress:0x007fefa4e5d058 @raw_email_address="hello@example.com">, preferences: {}, created_at: nil, updated_at: nil>
irb(main):005:0> user.save
   (0.2ms)  BEGIN
  SQL (0.6ms)  INSERT INTO "users" ("email", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id"  [["email", "hello@example.com"], ["created_at", "2015-08-21 19:40:37.639952"], ["updated_at", "2015-08-21 19:40:37.639952"]]
   (2.5ms)  COMMIT
=> true
irb(main):006:0> user.reload
  User Load (0.5ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1  [["id", 4]]
=> #<User id: 2, name: nil, email: #<EmailAddress:0x007fefa49ba668 @raw_email_address="hello@example.com">, preferences: {}, created_at: "2015-08-21 19:40:37", updated_at: "2015-08-21 19:40:37">
irb(main):007:0> user.email == "hello@example.com"
=> true
irb(main):008:0> User.find_by(email: "hello@example.com")
  User Load (1.8ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = $1 LIMIT 1  [["email", "hello@example.com"]]
=> #<User id: 2, name: nil, email: #<EmailAddress:0x007fefa4ecc840 @raw_email_address="hello@example.com">, preferences: {}, created_at: "2015-08-21 19:40:37", updated_at: "2015-08-21 19:40:37">
irb(main):009:0> User.find_by(email: EmailAddress.new("hello@example.com"))
  User Load (0.5ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = $1 LIMIT 1  [["email", "hello@example.com"]]
=> #<User id: 2, name: nil, email: #<EmailAddress:0x007fefa4ed6458 @raw_email_address="hello@example.com">, preferences: {}, created_at: "2015-08-21 19:40:37", updated_at: "2015-08-21 19:40:37">
irb(main):010:0> exit

As you can see from the above console session, we can use all the same ActiveRecord methods we're accustomed to using in everyday Rails: attr=, find_by, etc. We don't lose anything there. But there are real productivity gains such as never having to deal with nil when it comes to a user's email.

Conclusion

We've come a long way. We can serialize and deserialize a User's email address with a custom class that can compare email addresses without downcase being littered all over the codebase. We know we'll always have a valid email address no matter where we are in the application.

Where should we go from here? What else would you add to the EmailAddress class?

Categories: Software Development | Tags: Object Oriented Programming, Value Objects, Types

Portrait photo for Christopher Moeller Christopher Moeller

Christopher is a self-taught developer and has been working with Ruby and Ruby on Rails since 2010. He enjoys building the simple, elegant solution to current problem he's solving and has recently picked up functional programming, mostly with the Elixir programming language.

Comments


LET US HELP YOU!

We provide a free consultation to discover competitive advantages for your business. Contact us today to schedule an appointment.