skyler
skyler

Reputation: 8348

Unsure how to model a many-to-many relationship with a flag in Mongoid

I have two entities, projects and users. These are modeled in Rails using Mongoid with two Document instances, User and Project.

In this system, one user can create one project, but many users can follow many projects. For example, as user_id 1 I've created project_id 1. But user_ids 10, 11, 40, and 60 all follow project_id 1. I need to represent a many-to-many relationship between users and projects, and represent a specific user_id as the creator of the project, to assign him editing rights.

Practically speaking, when a user logs-in, he needs to be able to see all projects that he is following, including any that he created, commingled with other projects created by other users. His special creator status wont influence this list at all. When a user looks at a specific project, he needs to be able to view all users following a project, and if he's a creator, he can add new followers and delete existing ones.

In a RDBMS I would represents this with tables users, projects and a users_projects join table with a flag of is_creator. This would easily let me select which projects a user can see, and which users are followers, including which users are creators, of projects.

Mongoid supports many-to-many relationships, but unlike in an RDBMS there's no way for me to put a flag on the relationship. Instead, I'm thinking I'll add a creator field to the projects document, which will contain a link back to an _id field on the users document.

The user->projects relationship might look like this

class User
  has_and_belongs_to_many :projects
end

class Project
  has_and_belongs_to_many: users
end

But I can't figure out how to map the creator->created_projects relationship. I believe I can reference a user creator in Project like belongs_to :creator, :class_name => 'User' but I'm not sure how to set up the other side.

How best can I model these relationships in Mongoid?

Upvotes: 1

Views: 295

Answers (2)

sled
sled

Reputation: 14625

the second version uses less space but you need an extra query to get the user details like usernames. An object ID has 12bytes, max. document size is 16mb so the array could hold around 1.3M user ids (theoretically!)..

here you go:

user.rb

class User
  # projects this user owns
  has_many :projects
  has_many :followed_projects, 
           :class_name => 'Project', 
           :foreign_key => :follower_ids

  # Uncomment if the relation does not work
  #def followed_projects
  #  Project.where(:follower_ids => self.id)
  #end

  # get projects that this user has created and projects he is following
  def related_projects
    Project.any_of({:user_id  => self.id}, {:follower_ids => self.id})
  end
end

project.rb

class Project
  # creator
  belongs_to :user

  field :follower_ids, :type => Array

  # adds a follower
  def add_follower!(user_obj)
    # adds a user uniquely to the follower_ids array
    self.add_to_set(:follower_ids, user_obj.id)
  end

  def remove_follower!(user_obj)
    # remove the user
    self.pull(:follower_ids, user_obj.id)
  end
end

How to work with it:

@project    = Project.first
@some_user  = User.last

@project.add_follower!(@some_user)

@some_user.followed_projects
@some_user.related_projects

# create hash like @ids_to_user[user_id] = user
@ids_to_users = User.find(@project.follower_ids).inject({}) {|hsh, c_user| hsh[c_user.id] = c_user; hsh}

@project.followers.each do |c_follower|
  puts "I'm #{@ids_to_users[c_follower].username} and I'm following this project!"
end

@project.remove_follower!(@some_user)

Upvotes: 2

sled
sled

Reputation: 14625

create an embedded document which holds all followers with their user_id and their username so you won't have to query the follower's usernames.

The benefits:

  • Not a single query to lookup a project's followers
  • Only a single query to lookup a user's followed projects

The downside:

  • If a user changes his name, you'll have to update all his "followships" but how often do you change your name compared to how often you lookup your followed projects ;)

  • If you have many thousand followers per project you may reach the document limit of 16mb

user.rb

class User
  # projects this user owns
  has_many :projects

  def followed_projects
    Project.where('followers.user_id' => self.id)
  end

  # get projects that this user has created and projects he is following
  def related_projects
    Project.any_of({:user_id  => self.id}, {'followers.user_id' => self.id})
  end
end

project.rb

class Project
  # creator
  belongs_to :user

  embeds_many :followers

  # add an index because we're going to query on this
  index 'followers.user_id'

  # adds a follower
  # maybe you want to add some validations, preventing duplicate followers
  def add_follower!(user_obj)
    self.followers.create({
      :user       => user_obj,
      :username   => user_obj.username
    })
  end

  def remove_follower!(user_obj)
    self.followers.destroy_all(:conditions => {:user_id => user_obj.id})
  end

end

follower.rb

class Follower
  include Mongoid::Document
  include Mongoid::Timestamps

  embedded_in :project

  # reference to the real user
  belongs_to :user

  # cache the username
  field :username, :type => String
end

How to work with it:

@project    = Project.first
@some_user  = User.last

@project.add_follower!(@some_user)

@some_user.followed_projects
@some_user.related_projects

@project.followers.each do |c_follower|
  puts "I'm #{c_follower.username} and I'm following this project!"
end

@all_follower_user_ids = @project.followers.map{|c| c.user_id}

# find a specific follower by user_id 
@some_follower = @project.followers.where(:user_id => 1234)
# find a specific follower by username
@some_follower = @project.followers.where(:username => 'The Dude')

@project.remove_follower!(@some_user)

PS: If you want a simpler solution, you could just embedd an array of ObjectIDs (user_ids) in the project and use the atomic updates $addToSet and $pullAll to add/remove a follower. But you'd need an extra query like User.where(:user_id.in => @project.follower_ids) (assuming the array is called follower_ids) to grab all users and their names ;)

Upvotes: 1

Related Questions