6
votes

I'm building a forum system in Rails in order to become more acquainted with Rails and Mongoid. A feature I'd like to add is a private message system forum users can use to message each other. In terms of schema design I can think of two solutions:

Solution 1

Users and Messages are separate documents linked to each other using "has_many" and "belongs_to".

User document

has_many :messages_sent, :class_name => 'Message', :inverse_of => :message_sender

has_many :messages_received, :class_name => 'Message', :inverse_of => :message_recipient

and

Message Document

field :created, type: DateTime, default: -> { Time.now }

field :content, type: String

belongs_to :message_sender, :class_name => 'User', :inverse_of => :messages_sent

belongs_to :message_recipient, :class_name => 'User', :inverse_of => :messages_received

In order to show a user his inbox I'd look at some_user.messages_received ordered by :created and filtered so I have a list of unique sender ids ordered by the time their last message was sent to some_user.

Then to show a specific conversation I'd just get all messages between the two participants and interleave them according to timestamps:

messages_in = some_user.messages_received.where(:message_sender => selected_correspondent)

messages_out = some_user.messages_sent.where(:message_recipient => selected_correspondent).

I don't like this solution because it involves hitting the Messages collection with "where" queries multiple times and a lot of manual filtering and interleaving of messages sent and received. Effort.

Solution 2 (which I'm using now)

Embed messages in a Conversation document. I will provide the code for User, Message and Conversation below. A Conversation is linked to two or more Users via has_and_belongs_to_many (n-n since a User may also have many Conversations). This could also potentially allow multi-user conversations.

I like this solution because in order to show a user his inbox I can just use some_user.conversations ordered by :last_message_received stored and updated in the Conversation document, no filtering required. To show a specific conversation I don't need to interleave messages sent and received as messages are already embedded in the Conversation document in the correct order.

The only problem with this solution is finding the correct Conversation document shared by two (or more) Users when you want to add a message. One solution is suggested here: mongodb conversation system, but I do not like it because the query seems relatively expensive and scaling for multi-user conversations looks like it will get tricky. Instead I have a field in the Conversation document named :lookup_hash which is a SHA1 hash calculated from the Object ids of each User participating in the conversation. This way, given two or more Users it is trivial to find their corresponding Conversation document (or create it if it doesn't exist yet).

To add a message to a conversation, I just use Conversation.add_message (class method, not instance method because the conversation may not exist yet) giving it a sender, recipient and new message object.

Question

My question is: Am I doing anything obviously wrong considering Mongoid (Or just NoSQL in general) schema design best practices? Is there anything I can do to improve my solution? Is my idea of using a hash to lookup Conversations a bad idea?

Code

user.rb

class User
  include Mongoid::Document

  field :username, type: String
  field :joined, type: DateTime, default: ->{ Time.now }
  field :last_activity, type: DateTime, default: -> { Time.now }

  has_and_belongs_to_many :conversations 
end

conversation.rb

require 'digest/sha1'

class Conversation
  include Mongoid::Document

  field :lookup_hash, type: String
  field :created, type: DateTime, default: -> { Time.now }
  field :last_message_time, type: DateTime, default: -> { Time.now }
  # Array of user ids of users that have read all messages in this conversation
  field :last_message_seen_by, type: Array, default: []

  embeds_many :messages
  has_and_belongs_to_many :participants, :class_name => 'User'

  validates_presence_of :lookup_hash

  index({ lookup_hash: 1 }, { unique: true, name: "lookup_hash_index" })
    # Used to show a user a list of conversations ordered by last_message_time
  index({ _id: 1, last_message_time: -1 }, { unique: true, name: "id_last_message_time_index" })

  def self.add_message(recipient, sender, message)
    # Find or create a conversation:
    conversation = Conversation.find_or_create_by(
      :lookup_hash => get_lookup_hash([recipient.id, sender.id])) do |c|
        c.participants.concat [recipient, sender]
      end
    conversation.messages << message
    conversation.last_message_time = Time.now
    conversation.last_message_seen_by.delete(recipient)
    conversation.save
  end

  private
    def self.get_lookup_hash(participant_ids)
      lookup_key = participant_ids.sort.join(':')
      Digest::SHA1.hexdigest lookup_key
    end
end

message.rb

class Message
  include Mongoid::Document

  field :created, type: DateTime, default: -> { Time.now }
  field :text, type: String

  embedded_in :conversation
  belongs_to :author, :class_name => 'User'

  validates_length_of :text, minimum: 2, maximum: 256
  validates_presence_of :author
end
1

1 Answers

2
votes

I gather that you are using MongoId 3.0. I do not see any problem in your first solution:

messages_in = some_user.messages_received.where(:message_sender => current_user)

messages_out = some_user.messages_sent.where(:message_recipient => current_user).

You can find various examples:

Preferred way to private messages modeling in Rails 3

http://pastebin.com/fKavivbp

https://groups.google.com/forum/?fromgroups=#!topic/mongoid/BOBqhYLb7O0

I have an internal messaging system on several projects with MongoId and use the first solution.

If you add other class "Conversation" You should not embed message, because a conversation can have an unlimited number of messages. you should use has_may messages and belongs_to conversation.

I think that both solutions are good, So you choose your needs for your project logic. If your logic is simpler, you can opt for the first solution. Otherwise, if your logic is more complex opts for the latter solution.

Regards!