Jump To …

chaining.rb

Chaining Ohm Sets

Doing the straight forward approach

Let’s design our example around the following requirements:

  1. a User has many orders.
  2. an Order can be pending, authorized or captured.
  3. a Product is referenced by an Order.

Doing it the normal way

Let’s first require Ohm.

require "ohm"

A User has a collection of orders. Note that a collection is actually just a convenience, which implemented simply will look like:

def orders
  Order.find(:user_id => self.id)
end
class User < Ohm::Model
  collection :orders, Order
end

The product for our purposes will only contain a name.

class Product < Ohm::Model
  attribute :name
end

We define an Order with just a single attribute called state, and also add an index so we can search an order given its state.

The reference to the User is actually required for the collection of orders in the User declared above, because the reference defines an index called :user_id.

We also define a reference to a Product.

class Order < Ohm::Model
  attribute :state
  index :state

  reference :user, User
  reference :product, Product
end

Testing what we have so far.

For the purposes of this tutorial, we’ll use cutest for our test framework.

require "cutest"

Make sure that every run of our test suite has a clean Redis instance.

prepare { Ohm.flush }

Let’s create a user, a pending, authorized and a captured order. We also create two products named iPod and iPad.

setup do
  @user = User.create

  @ipod = Product.create(:name => "iPod")
  @ipad = Product.create(:name => "iPad")

  @pending =    Order.create(:user => @user, :state => "pending",
                             :product => @ipod)
  @authorized = Order.create(:user => @user, :state => "authorized",
                             :product => @ipad)
  @captured =   Order.create(:user => @user, :state => "captured",
                             :product => @ipad)
end

Now let’s try and grab all pending orders, and also pending iPad and iPod ones.

test "finding pending orders" do
  assert @user.orders.find(state: "pending").include?(@pending)

  assert @user.orders.find(:state => "pending",
                           :product_id => @ipod.id).include?(@pending)

  assert @user.orders.find(:state => "pending",
                           :product_id => @ipad.id).empty?
end

Now we try and find captured and authorized orders. The tricky part is trying to find an order that is either captured or authorized, since Ohm as of this writing doesn’t support unions in its finder syntax.

test "finding authorized and/or captured orders" do
  assert @user.orders.find(:state => "authorized").include?(@authorized)
  assert @user.orders.find(:state => "captured").include?(@captured)

  assert @user.orders.find(:state => ["authorized", "captured"]).empty?

  auth_or_capt = @user.orders.key.volatile[:auth_or_capt]
  auth_or_capt.sunionstore(
    @user.orders.find(:state => "authorized").key,
    @user.orders.find(:state => "captured").key
  )

  assert auth_or_capt.smembers.include?(@authorized.id)
  assert auth_or_capt.smembers.include?(@captured.id)
end

Creating shortcuts

You can of course define methods to make that code more readable.

class User < Ohm::Model
  def authorized_orders
    orders.find(:state => "authorized")
  end

  def captured_orders
    orders.find(:state => "captured")
  end
end

And we can now test these new methods.

test "finding authorized and/or captured orders" do
  assert @user.authorized_orders.include?(@authorized)
  assert @user.captured_orders.include?(@captured)
end

In most cases this is fine, but if you want to have a little fun, then we can play around with some chainability.

Chaining Kung-Fu

The Ohm::Model::Set takes a Redis key and a class monad for its arguments.

We can simply subclass it and define the monad to always be an Order so we don’t have to manually set it everytime.

class UserOrders < Ohm::Model::Set
  def initialize(key)
    super key, Ohm::Model::Wrapper.wrap(Order)
  end

Here is the crux of the chaining pattern. Instead of just doing a straight up find(:state => “pending”), we return UserOrders again.

  def pending
    self.class.new(model.index_key_for(:state, "pending"))
  end

  def authorized
    self.class.new(model.index_key_for(:state, "authorized"))
  end

  def captured
    self.class.new(model.index_key_for(:state, "captured"))
  end

Now we wrap the implementation of doing an SUNIONSTORE and also make it return a UserOrders object.

NOTE: volatile just returns the key prepended with a ~:, so in this case it would be ~:Order:accepted.

  def accepted
    model.key.volatile[:accepted].sunionstore(
      authorized.key, captured.key
    )

    self.class.new(model.key.volatile[:accepted])
  end
end

Now let’s re-open the User class and add a customized orders method.

class User < Ohm::Model
  def orders
    UserOrders.new(Order.index_key_for(:user_id, id))
  end
end

Ok! Let’s put all of that chaining code to good use.

test "finding pending orders using a chainable style" do
  assert @user.orders.pending.include?(@pending)
  assert @user.orders.pending.find(:product_id => @ipod.id).include?(@pending)

  assert @user.orders.pending.find(:product_id => @ipad.id).empty?
end

test "finding authorized and/or captured orders using a chainable style" do
  assert @user.orders.authorized.include?(@authorized)
  assert @user.orders.captured.include?(@captured)

  assert @user.orders.accepted.include?(@authorized)
  assert @user.orders.accepted.include?(@captured)

  accepted = @user.orders.accepted

  assert accepted.find(:product_id => @ipad.id).include?(@authorized)
  assert accepted.find(:product_id => @ipad.id).include?(@captured)
end

Conclusion

This design pattern is something that really depends upon the situation. In the example above, you can add more complicated querying on the UserOrders class.

The most important takeaway here is the ease of which we can weild the different components of Ohm, and mold it accordingly to our preferences, without having to monkey-patch anything.