Improve Your Ruby with the Adapter Design Pattern

Robert Qualls
Share

Imagine we have some code where we want to accomplish things in a variety of ways. One way to do this is with conditional branching:

class Animal
  def speak(kind)
    puts case kind
    when :dog then "woof!"
    when :cat then "meow!"
    when :owl then "hoo!"
    end
  end
end

Animal.new.speak(:dog)

This works, but what if a developer wants to add a new way? With conditional branching, the entire method would need to be overwritten. Instead, we can separate the implementations into modules:

class Animal
  module Adapter
    module Dog
      def self.speak
        puts "woof!"
      end
    end

    module Cat
      def self.speak
        puts "meow!"
      end
    end
  end

  def speak
    self.adapter.speak
  end

  def adapter
    return @adapter if @adapter
    self.adapter = :dog
    @adapter
  end

  def adapter=(adapter)
    @adapter = Animal::Adapter.const_get(adapter.to_s.capitalize)
  end
end

animal = Animal.new
animal.speak
animal.adapter = :cat
aanimal.speak

This is a lot more code! However, if we want to add another module, it’s not too bad and a lot more flexible:

class Animal
  module Adapter
    module Owl
      def self.speak
        puts "hoo!"
      end
    end
  end
end

animal.adapter = :owl
animal.speak

This new module could even go in a separate gem – and with its own dependencies! Organizing things this way is called the adapter design pattern. Let’s look at a few examples of this pattern in the wild.

multi_json

A good example is the multi_json gem which parses JSON with the fastest available backend. In multi_json, each backend is contained in an class that descends from Adapter. Here’s multi_json/lib/multi_json/adapters/gson.rb.

require 'gson'
require 'stringio'
require 'multi_json/adapter'

module MultiJson
  module Adapters
    # Use the gson.rb library to dump/load.
    class Gson < Adapter
      ParseError = ::Gson::DecodeError

      def load(string, options = {})
        ::Gson::Decoder.new(options).decode(string)
      end

      def dump(object, options = {})
        ::Gson::Encoder.new(options).encode(object)
      end
    end
  end
end

Here, load executes each library’s method for turning a JSON string into an object, and dump executes the method for turning an object into a string.

ActiveRecord

ActiveRecord is Rails’ ORM library for interacting with relational databases. It relies on the adapter pattern to allow the developer to interact with any supported database using the same methods. We can find this pattern in ActiveRecord‘s connection_adapters.

module ActiveRecord
  module ConnectionAdapters # :nodoc:
    extend ActiveSupport::Autoload

    autoload :Column
    autoload :ConnectionSpecification

    autoload_at 'active_record/connection_adapters/abstract/schema_definitions' do
      autoload :IndexDefinition
      autoload :ColumnDefinition
      autoload :ChangeColumnDefinition
      autoload :ForeignKeyDefinition
      autoload :TableDefinition
      autoload :Table
      autoload :AlterTable
      autoload :ReferenceDefinition
    end

    autoload_at 'active_record/connection_adapters/abstract/connection_pool' do
      autoload :ConnectionHandler
      autoload :ConnectionManagement
    end

    autoload_under 'abstract' do
      autoload :SchemaStatements
      autoload :DatabaseStatements
      autoload :DatabaseLimits
      autoload :Quoting
      autoload :ConnectionPool
      autoload :QueryCache
      autoload :Savepoints
    end

    ...

    class AbstractAdapter
      ADAPTER_NAME = 'Abstract'.freeze
      include Quoting, DatabaseStatements, SchemaStatements
      include DatabaseLimits
      include QueryCache
      include ActiveSupport::Callbacks
      include ColumnDumper

      SIMPLE_INT = /\A\d+\z/

      define_callbacks :checkout, :checkin

      attr_accessor :visitor, :pool
      attr_reader :schema_cache, :owner, :logger
      alias :in_use? :owner

      ...

      attr_reader :prepared_statements

      def initialize(connection, logger = nil, config = {}) # :nodoc:
        super()

        @connection          = connection
        @owner               = nil
        @instrumenter        = ActiveSupport::Notifications.instrumenter
        @logger              = logger
        @config              = config
        @pool                = nil
        @schema_cache        = SchemaCache.new self
        @visitor             = nil
        @prepared_statements = false
      end

      ...

ActiveRecord includes many adapters, including MySQL and PostgreSQL here. Look through a couple of those to see great examples of this pattern.

Moneta

One of my favorite gems is moneta which is a unified interface to key-value stores, such as Redis. Here is an example of using a file as a key-value store:

require 'moneta'

# Create a simple file store
store = Moneta.new(:File, dir: 'moneta')

# Store some entries
store['key'] = 'value'

# Read entry
store.key?('key') # returns true
store['key'] # returns 'value'

store.close

From the user’s perspective, accessing both redis and daybreak is as simple as reading or modifying a hash. Here’s what the daybreak adapter looks like (comments removed to save space):

require 'daybreak'

module Moneta
  module Adapters
    class Daybreak < Memory
      def initialize(options = {})
        @backend = options[:backend] ||
          begin
            raise ArgumentError, 'Option :file is required' unless
options[:file]
            ::Daybreak::DB.new(options[:file], serializer:
::Daybreak::Serializer::None)
          end
      end

      def load(key, options = {})
        @backend.load if options[:sync]
        @backend[key]
      end

      def store(key, value, options = {})
        @backend[key] = value
        @backend.flush if options[:sync]
        value
      end

      def increment(key, amount = 1, options = {})
        @backend.lock { super }
      end

      def create(key, value, options = {})
        @backend.lock { super }
      end

      def close
        @backend.close
      end
    end
  end
end

Creating an Adapter Gem

Let’s make a gem that allows the user to choose an adapter for a rudimentary CSV parser. Here’s what our folder structure will look like:

├── Gemfile
├── Rakefile
├── lib
│   ├── table_parser
│   │   └── adapters
│   │       ├── scan.rb
│   │       └── split.rb
│   └── table_parser.rb
└── test
    ├── helper.rb
    ├── scan_adapter_test.rb
    ├── split_adapter_test.rb
    └── table_parser_test.rb

Dependencies

Add minitest and ruby "2.3.0" to the Gemfile:

# Gemfile
source "https://rubygems.org"

ruby "2.3.0"

gem "minitest", "5.8.3"

Ruby 2.3 adds the new squiggly heredoc syntax which will be useful in this case as it prevents unnecessary leading whitespace. Adding it to the Gemfile will not install it. It will need to be installed separately with a command like (if you use RVM):

$ rvm install 2.3.0

Test Support

Add a Rakefile that lets use rake to run all of our tests:

# Rakefile
require "rake/testtask"

Rake::TestTask.new do |t|
  t.pattern = "test/*_test.rb"
  t.warning = true
  t.libs << 'test'
end

task default: :test

t.libs << 'test' adds the test folder to our $LOAD_PATH when running the task. The lib folder is included by default.

The Main Module

lib/table_parser.rb will implement what the user accesses when they use the gem:

# lib/table_parser.rb

module TableParser
  extend self

  def parse(text)
    self.adapter.parse(text)
  end

  def adapter
    return @adapter if @adapter
    self.adapter = :split
    @adapter
  end

  def adapter=(adapter)
    require "table_parser/adapters/#{adapter}"
    @adapter = TableParser::Adapter.const_get(adapter.to_s.capitalize)
  end
end

::adapter sets a default adapter the first time it is called. Notice that adapters are not loaded until they are set. This avoids exposing developers to bugs in unused adapters and allows adapters in the same project to use their own dependencies without requiring all dependencies for all adapters up front.

The Adapters

The first adapter parses using the scan method with an appropriate regular expression. The regex delimiter searches for either anything that is not a comma or two consecutive commas:

# lib/table_parser/adapters/scan.rb

module TableParser
  module Adapter
    module Scan
      extend self

      def parse(text)
        delimiter = /[^,]+|,,/
        lines = text.split(/\n/)

        keys = lines.shift.scan(delimiter).map { |key| key.strip }

        rows = lines.map do |line|
          row = {}
          fields = line.scan(delimiter)
          keys.each do |key|
            row[key] = fields.shift.strip
            row[key] = "" if row[key] == ",,"
          end
          row
        end

        return rows
      end
    end
  end
end

The second adapter parses using the split method with another regular expression:

# lib/table_parser/adapters/split.rb

module TableParser
  module Adapter
    module Split
      extend self

      def parse(text)
        delimiter = / *, */
        lines = text.split(/\n/)
        keys = lines.shift.split(delimiter, -1)

        rows = lines.map do |line|
          row = {}
          fields = line.split(delimiter, -1)
          keys.each { |key| row[key] = fields.shift }
          row
        end

        return rows
      end
    end
  end
end

Test Helper

The main thing that needs to be done here is setting up the minitest dependencies, and we can go ahead and load the project code as well. This is not really a necessary file here, but it’s common in larger projects.

# test/test_helper.rb
require "minitest/autorun"
require "table_parser"

Shared Test Examples

We should avoid duplicating all tests for each adapter. Instead, we will write shared examples that will be pulled in from simpler adapter test files:

# test/table_parser_test.rb
require "test_helper"

module TableParserTest
  def test_parse_columns_and_rows
    text = <<~TEXT
      Name,LastName
      John,Doe
      Jane,Doe
    TEXT

    john, jane = TableParser.parse(text)

    assert_equal "John", john["Name"]
    assert_equal "Jane", jane["Name"]

    assert_equal "Doe", john["LastName"]
    assert_equal "Doe", jane["LastName"]
  end

  def test_empty
    text = <<~TEXT
      Name,LastName
    TEXT

    result = TableParser.parse(text)

    assert_equal [], result
  end

  def test_removes_leading_and_trailing_whitespace
    text = <<~TEXT
      ,  Name,LastName
      ,John    ,  Doe
      ,     Jane,       Doe
    TEXT

    john, jane = TableParser.parse(text)

    assert_equal "John", john["Name"]
    assert_equal "Jane", jane["Name"]

    assert_equal "Doe", john["LastName"]
    assert_equal "Doe", jane["LastName"]
  end
end

Adapter Test Files

Next, for each adapter we need a test that will run the shared examples on it. Since the test examples are in a separate file and shared, these are pretty compact.

First, one for the scanning adapter:

# test/scan_adapter_test.rb
require "table_parser_test"

class TableParser::ScanAdapterTest < Minitest::Test
  include TableParserTest

  def setup
    TableParser.adapter = :scan
  end
end

Next, for the splitting adapter:

# test/split_adapter_test.rb
require "table_parser_test"

class TableParser::SplitAdapterTest < Minitest::Test
  include TableParserTest

  def setup
    TableParser.adapter = :split
  end
end

Running the Test Suite

Thanks to the Rakefile, verifying that both of the adapters work is easy;

$ rake

Run options: --seed 26993

# Running:

......

Fabulous run in 0.001997s, 3004.7896 runs/s, 9014.3689 assertions/s.

6 runs, 18 assertions, 0 failures, 0 errors, 0 skips

Conclusion

Adapters are great ways to incorporate multiple ways of accomplishing something without resorting to mountains of conditional branching. They also let you split approaches into separate libraries that can have their own dependencies. If adapters are loaded in a lazy manner, broken adapters will not affect a project unless they are used.

Stay tuned for more great design pattern articles!

CSS Master, 3rd Edition