Note that there are some explanatory texts on larger screens.

plurals
  1. POMongoDB Conversation / Private Message Schema using Mongoid
    primarykey
    data
    text
    <p>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:</p> <h2>Solution 1</h2> <p>Users and Messages are separate documents linked to each other using "has_many" and "belongs_to".</p> <blockquote> <p><strong>User document</strong></p> <p>has_many :messages_sent, :class_name => 'Message', :inverse_of => :message_sender</p> <p>has_many :messages_received, :class_name => 'Message', :inverse_of => :message_recipient</p> </blockquote> <p>and</p> <blockquote> <p><strong>Message Document</strong></p> <p>field :created, type: DateTime, default: -> { Time.now }</p> <p>field :content, type: String</p> <p>belongs_to :message_sender, :class_name => 'User', :inverse_of => :messages_sent</p> <p>belongs_to :message_recipient, :class_name => 'User', :inverse_of => :messages_received</p> </blockquote> <p>In order to show a user his inbox I'd look at <code>some_user.messages_received</code> ordered by <code>:created</code> and filtered so I have a list of unique sender ids ordered by the time their last message was sent to <code>some_user</code>.</p> <p>Then to show a specific conversation I'd just get all messages between the two participants and interleave them according to timestamps: </p> <blockquote> <p>messages_in = some_user.messages_received.where(:message_sender => selected_correspondent)</p> <p>messages_out = some_user.messages_sent.where(:message_recipient => selected_correspondent).</p> </blockquote> <p>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.</p> <h2>Solution 2 (which I'm using now)</h2> <p>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 <code>has_and_belongs_to_many</code> (n-n since a User may also have many Conversations). This could also potentially allow multi-user conversations. </p> <p>I like this solution because in order to show a user his inbox I can just use <code>some_user.conversations</code> ordered by <code>:last_message_received</code> 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.</p> <p>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: <a href="https://stackoverflow.com/questions/9621952/mongodb-conversation-system">mongodb conversation system</a>, 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 <code>:lookup_hash</code> 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).</p> <p>To add a message to a conversation, I just use <code>Conversation.add_message</code> (class method, not instance method because the conversation may not exist yet) giving it a sender, recipient and new message object.</p> <h2>Question</h2> <p>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?</p> <h2>Code</h2> <p><strong>user.rb</strong></p> <pre class="lang-rb prettyprint-override"><code>class User include Mongoid::Document field :username, type: String field :joined, type: DateTime, default: -&gt;{ Time.now } field :last_activity, type: DateTime, default: -&gt; { Time.now } has_and_belongs_to_many :conversations end </code></pre> <p><strong>conversation.rb</strong></p> <pre class="lang-rb prettyprint-override"><code>require 'digest/sha1' class Conversation include Mongoid::Document field :lookup_hash, type: String field :created, type: DateTime, default: -&gt; { Time.now } field :last_message_time, type: DateTime, default: -&gt; { 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 =&gt; '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 =&gt; get_lookup_hash([recipient.id, sender.id])) do |c| c.participants.concat [recipient, sender] end conversation.messages &lt;&lt; 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 </code></pre> <p><strong>message.rb</strong></p> <pre class="lang-rb prettyprint-override"><code>class Message include Mongoid::Document field :created, type: DateTime, default: -&gt; { Time.now } field :text, type: String embedded_in :conversation belongs_to :author, :class_name =&gt; 'User' validates_length_of :text, minimum: 2, maximum: 256 validates_presence_of :author end </code></pre>
    singulars
    1. This table or related slice is empty.
    plurals
    1. This table or related slice is empty.
    1. This table or related slice is empty.
    1. This table or related slice is empty.
 

Querying!

 
Guidance

SQuiL has stopped working due to an internal error.

If you are curious you may find further information in the browser console, which is accessible through the devtools (F12).

Reload