In Progress
Unit 1, Lesson 1
In Progress

YAML::Store

Video transcript & code

In the last episode we were introduced to PStore, a simple file-based transactional persistence tool from Ruby's standard library.

We used it to store entries in a TODO list to disk. It works great for this purpose. But one day after making some changes to the Lister library, we discover our TODO list tasks aren't being saved properly.

require "./lister"

lister = Lister.new
lister.add_list("ship chores") do |list|
  list.add_task("Early rise (3:00PM)")
  list.add_task("Waffles vindaloo with Kryten")
  list.add_task("Call Rimmer a smeghead")
end

require "pstore"
store = PStore.new("todo.pstore")

store.transaction do |s|
  s["lister"] = lister
end
require "./seed"

store = PStore.new("todo.pstore")
store.transaction do
  store["lister"].lists.first.tasks # => []
end

In order to debug the problem, we first want to check if the tasks are being written out to disk. So we take a look at the contents of the todo.pstore file. And what we see is gobbledygook.

This file is in Ruby's binary marshaling format. Which means it is very efficient for Ruby to read it in and write it out. Unfortunately, it's not so easy for us humans to interpret.

Wouldn't it be nice if we could have all the convenience of PStore, but serialized in a human-readable format like YAML? Well, as you might have guessed from the title of this article, we can have our cake and eat it too. By requiring the yaml/store library, and using YAML::Store everywhere we used PStore previously, we can store our TODO list to a YAML file instead. These are the only changes we need to make; YAML::Store has the exact same API as PStore, so it is completely interchangeable.

require "./lister"

lister = Lister.new
lister.add_list("ship chores") do |list|
  list.add_task("Early rise (3:00PM)")
  list.add_task("Waffles vindaloo with Kryten")
  list.add_task("Call Rimmer a smeghead")
end

require "yaml/store"
store = YAML::Store.new("todo.yaml")

store.transaction do |s|
  s["lister"] = lister
end

store = YAML::Store.new("todo.yaml")
store.transaction do
  store["lister"].lists.first.tasks # => []
end

Let's take a look at the todo.yaml file.

$ cat todo.yaml
---
lister: !ruby/object:Lister
  lists:
  - !ruby/struct:Lister::List
    name: ship chores
    tasks: []

Sure enough, the tasks aren't being written out. We revisit the lister library, and realize we must have accidentally deleted the line that yields to a block given to #add_list.

class Lister
  List = Struct.new(:name, :tasks) do
    def initialize(name)
      super(name, [])
    end

    def add_task(name)
      task = Task.new(name)
      yield task if block_given?
      tasks << task
      task
    end
  end

  Task = Struct.new(:name, :status) do
    def initialize(name)
      super(name, :todo)
    end
  end

  attr_reader :lists

  def initialize
    @lists = []
  end

  def add_list(name)
    list = List.new(name)
    yield list if block_given?
    lists << list
    list
  end
end

When we fix this and re-run our test script, we can see that the tasks are now saved.

require "./lister2"

lister = Lister.new
lister.add_list("ship chores") do |list|
  list.add_task("Early rise (3:00PM)")
  list.add_task("Waffles vindaloo with Kryten")
  list.add_task("Call Rimmer a smeghead")
end

require "yaml/store"
store = YAML::Store.new("todo.yaml")

store.transaction do |s|
  s["lister"] = lister
end

store = YAML::Store.new("todo.yaml")
store.transaction do
  store["lister"].lists.first.tasks
  # => [#<struct Lister::Task name="Early rise (3:00PM)", status=:todo>,
  #     #<struct Lister::Task name="Waffles vindaloo with Kryten", status=:todo>,
  #     #<struct Lister::Task name="Call Rimmer a smeghead", status=:todo>]
end

And when we dump the YAML file again, we can see that they are included.

$ cat todo.yaml
---
lister: !ruby/object:Lister
  lists:
  - !ruby/struct:Lister::List
    name: ship chores
    tasks:
    - !ruby/struct:Lister::Task
      name: Early rise (3:00PM)
      status: :todo
    - !ruby/struct:Lister::Task
      name: Waffles vindaloo with Kryten
      status: :todo
    - !ruby/struct:Lister::Task
      name: Call Rimmer a smeghead
      status: :todo

So is there any reason not to use YAML::Store? Well, YAML is easy for us humans to read, but Ruby can't parse and dump it as fast as it can read and write binary. As a result, in some very rudimentary benchmarking I've performed, YAML::Store has turned out to be around an order of magnitude slower than PStore. So my advice is this: use YAML::Store by preference; but if you find it too slow for your needs, drop in PStore as an optimization. Happy hacking!

require 'benchmark'
require 'yaml/store'
require 'fileutils'
FileUtils.rm_f('bm_blog.yml')
FileUtils.rm_f('bm_blog.pstore')
repo = YAML::Store.new('bm_blog.yml')
Post = Struct.new(:title,:body)
pstore_repo = PStore.new('bm_blog.pstore')
Benchmark.bm do |b|
  b.report('yaml') do
    1000.times do |i|
      repo.transaction do |r|
        (r["posts"] ||= []) << Post.new("Post #{i}")
      end
    end
  end
  b.report('pstore') do
    1000.times do |i|
      pstore_repo.transaction do |r|
        (r["posts"] ||= []) << Post.new("Post #{i}")
      end
    end
  end
end

Responses