Jump To …

one-to-many.rb

One to Many Ohm style

Problem

Let’s say you want to implement a commenting system, and you need to have comments on different models. In order to do this using an RDBMS you have one of two options:

  1. Have multiple comment tables per type i.e. VideoComments, AudioComments, etc.
  2. Use a polymorphic schema.

The problem with option 1 is that you’ll may possibly run into an explosion of tables.

The problem with option 2 is that if you have many comments across the whole site, you’ll quickly hit the limit on a table, and eventually need to shard.

Solution

In Redis, possibly the best data structure to model a comment would be to use a List, mainly because comments are always presented within the context of the parent entity, and are typically ordered in a predefined way (i.e. latest at the top, or latest at the bottom).

Let’s start by requiring Ohm.

require "ohm"

We define both a Video and Audio model, with a list of comments.

class Video < Ohm::Model
  list :comments, Comment
end

class Audio < Ohm::Model
  list :comments, Comment
end

The Comment model for this example will just contain one attribute called body.

class Comment < Ohm::Model
  attribute :body
end

Now let’s require the test framework we’re going to use called cutest

require "cutest"

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

prepare { Ohm.flush }

Let’s begin testing. The important thing to verify is that video comments and audio comments don’t munge with each other.

We can see that they don’t since each of the comments list only has one element.

test "adding all sorts of comments" do
  video = Video.create

  video_comment = Comment.create(:body => "First Video Comment")
  video.comments.push(video_comment)

  audio = Audio.create
  audio_comment = Comment.create(:body => "First Audio Comment")
  audio.comments.push(audio_comment)

  assert video.comments.include?(video_comment)
  assert video.comments.size == 1

  assert audio.comments.include?(audio_comment)
  assert audio.comments.size == 1
end

Discussion

As you can see above, the design is very simple, and leaves little to be desired.

Latest first ordering can simply be achieved by using unshift instead of push.

test "latest first ordering" do
  video = Video.create

  first = Comment.create(:body => "First")
  second = Comment.create(:body => "Second")

  video.comments.unshift(first)
  video.comments.unshift(second)

  assert [second, first] == video.comments.to_a
end

In addition, since Lists are optimized for doing LRANGE operations, pagination of Comments would be very fast compared to doing a LIMIT / OFFSET query in SQL (some sites also use WHERE id > ? LIMIT N and pass the previous last ID in the set).

test "getting paged chunks of comments" do
  video = Video.create

  20.times { |i| video.comments.push(Comment.create(:body => "C#{i + 1}")) }

  assert %w(C1 C2 C3 C4 C5)      ==  video.comments[0, 4].map(&:body)
  assert %w(C6 C7 C8 C9 C10)     ==  video.comments[5, 9].map(&:body)

** Range style is also supported.

  assert %w(C11 C12 C13 C14 C15) ==  video.comments[10..14].map(&:body)

** Also you can just pass in a single number.

  assert "C16" == video.comments[15].body
end

Caveats

Sometimes you need to be able to delete comments. For these cases, you might possibly need to store a reference back to the parent entity. Also, if you expect to store millions of comments for a single entity, it might be tricky to delete comments, as you need to manually loop through the entire LIST.

Luckily, there is a clean alternative solution, which would be to use a SORTED SET, and to use the timestamp (or the negative of the timestamp) as the score to maintain the desired order. Deleting a comment from a SORTED SET would be a simple ZREM call.