Injectable Persistence Layers – a Refactoring Step by Step

September 28, 2012 Matthew Parker

This week I worked on a new web interface for our open source project License Finder.

The license_finder gem persists dependency information out to a YAML file; however, we wanted to persist these same
dependency objects to a SQL database for the website.

Step 1: Persistence base class

In order to accomplish this, I had to perform a series of refactorings that would make it possible to swap out YAML persistence
with an ActiveRecord persistence layer. The LicenseFinder::Dependency class had never been setup make persistence injectable,
so the first step was moving all persistence-related functionality out into a seperate base class, and giving it an ActiveRecord-style API:

module LicenseFinder
  module Persistence
    class Dependency
      class Database
        #... YAML 'database' implementation details
      end

      attr_accessor *LicenseFinder::DEPENDENCY_ATTRIBUTES

      class << self
        def find_by_name(name)
          attributes = database.find { |a| a['name'] == name }
          new(attributes) if attributes
        end

        def delete_all
          database.delete_all
        end

        def all
          database.all.map { |attributes| new(attributes) }
        end

        def unapproved
          all.select {|d| d.approved == false }
        end

        def update(attributes)
          database.update attributes
        end

        def destroy_by_name(name)
          database.destroy_by_name name
        end

        private
        def database
          @database ||= Database.new
        end
      end

      def initialize(attributes = {})
        update_attributes_without_saving attributes
      end

      def config
        LicenseFinder.config
      end

      def update_attributes new_values
        update_attributes_without_saving(new_values)
        save
      end

      def approved?
        !!approved
      end

      def save
        self.class.update(attributes)
      end

      def destroy
        self.class.destroy_by_name(name)
      end

      def attributes
        attributes = {}

        LicenseFinder::DEPENDENCY_ATTRIBUTES.each do |attrib|
          attributes[attrib] = send attrib
        end

        attributes
      end

      private
      def update_attributes_without_saving(new_values)
        new_values.each do |key, value|
          send("#{key}=", value)
        end
      end
    end
  end
end

With all of the persistence-related functionality in this base class, I could now update the LicenseFinder::Dependency class to inherit from this:

module LicenseFinder
  class Dependency < LicenseFinder::Persistence::Dependency
    #...
  end
end

I also created a shared example for describing how persistence should work (regardless of the underlying persistence implementation):

shared_examples_for "a persistable dependency" do
  let(:klass) { described_class }

  let(:attributes) do
    {
      'name' => "spec_name",
      'version' => "2.1.3",
      'license' => "GPLv2",
      'approved' => false,
      'notes' => 'some notes',
      'homepage' => 'homepage',
      'license_files' => ['/Users/pivotal/foo/lic1', '/Users/pivotal/bar/lic2'],
      'readme_files' => ['/Users/pivotal/foo/Readme1', '/Users/pivotal/bar/Readme2'],
      'source' => "bundle",
      'bundler_groups' => ["test"]
    }
  end

  before do
    klass.delete_all
  end

  describe '.new' do
    subject { klass.new(attributes) }

    context "with known attributes" do
      it "should set the all of the attributes on the instance" do
        attributes.each do |key, value|
          if key != "approved"
            subject.send("#{key}").should equal(value), "expected #{value.inspect} for #{key}, got #{subject.send("#{key}").inspect}"
          else
            subject.approved?.should == value
          end
        end
      end
    end

    context "with unknown attributes" do
      before do
        attributes['foo'] = 'bar'
      end

      it "should raise an exception" do
        expect { subject }.to raise_exception(NoMethodError)
      end
    end
  end

  describe '.unapproved' do
    it "should return all unapproved dependencies" do
      klass.new(name: "unapproved dependency", approved: false).save
      klass.new(name: "approved dependency", approved: true).save

      unapproved = klass.unapproved
      unapproved.count.should == 1
      unapproved.collect(&:approved?).any?.should be_false
    end
  end

  describe '.find_by_name' do
    subject { klass.find_by_name gem_name }
    let(:gem_name) { "foo" }

    context "when a gem with the provided name exists" do
      before do
        klass.new(
          'name' => gem_name,
          'version' => '0.0.1'
        ).save
      end

      its(:name) { should == gem_name }
      its(:version) { should == '0.0.1' }
    end

    context "when no gem with the provided name exists" do
      it { should == nil }
    end
  end

  describe "#config" do
    it 'should respond to it' do
      klass.new.should respond_to(:config)
    end
  end

  describe '#attributes' do
    it "should return a hash containing the values of all the accessible properties" do
      dep = klass.new(attributes)
      attributes = dep.attributes
      LicenseFinder::DEPENDENCY_ATTRIBUTES.each do |name|
        attributes[name].should == dep.send(name)
      end
    end
  end

  describe '#save' do
    it "should persist all of the dependency's attributes" do
      dep = klass.new(attributes)
      dep.save

      saved_dep = klass.find_by_name(dep.name)

      attributes.each do |key, value|
        if key != "approved"
          saved_dep.send("#{key}").should eql(value), "expected #{value.inspect} for #{key}, got #{saved_dep.send("#{key}").inspect}"
        else
          saved_dep.approved?.should == value
        end
      end
    end
  end

  describe "#update_attributes" do
    it "should update the provided attributes with the provided values" do
      gem = klass.new(attributes)
      updated_attributes = {"version" => "new_version", "license" => "updated_license"}
      gem.update_attributes(updated_attributes)

      saved_gem = klass.find_by_name(gem.name)
      saved_gem.version.should == "new_version"
      saved_gem.license.should == "updated_license"
    end
  end

  describe "#destroy" do
    it "should remove itself from the database" do
      foo_dep = klass.new(name: "foo")
      bar_dep = klass.new(name: "bar")
      foo_dep.save
      bar_dep.save

      expect { foo_dep.destroy }.to change { klass.all.count }.by -1

      klass.all.count.should == 1
      klass.all.first.name.should == "bar"
    end
  end
end

Step 2 – Make persistence autoloadable

Next, I wanted to make persistence autoloadable in the gem (so that other persistence solutions could simply create their own
LicenseFinder::Persistence::Dependency implementation before doing a require "license_finder":

module LicenseFinder
  module Persistence
    autoload :Dependency, 'license_finder/persistence/yaml/dependency'
    autoload :Configuration, 'license_finder/persistence/yaml/configuration'
  end
end

Step 3 – Create new persistence implementation

Now, creating an ActiveRecord persistence implementation was as simple as:

module LicenseFinder
  module Persistence
    class Dependency < ActiveRecord::Base
      serialize :license_files
      serialize :readme_files
      serialize :bundler_groups
      serialize :children
      serialize :parents

      belongs_to :config

      scope :unapproved, where(approved: false)
    end
  end
end

require "license_finder"

And the test for this persistence implementation:

require "spec_helper"
require_relative "path/to/LicenseFinder/spec/support/shared_examples/persistence/dependency.rb"

describe LicenseFinder::Persistence::Dependency do
  it_behaves_like "a persistable dependency"
end

About the Author

Matthew Parker

Matt Parker is Head of Engineering for Pivotal Labs

More Content by Matthew Parker
Previous
Google Hangouts an improvement over Skype
Google Hangouts an improvement over Skype

The Problem Skype connectivity sometimes drops calls. It is also challenging to schedule with multiple pe...

Next
Chrome 22.0.1229.79 Rendering Bug
Chrome 22.0.1229.79 Rendering Bug

The latest update for Google Chrome for OSX has a rendering issue where web pages render once upon load and...

Enter curious. Exit smarter.

Learn More