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:
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.
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.
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?
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
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!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With