Search code examples
ruby-on-railshas-and-belongs-to-manyactivemodelruby-on-rails-6

Rails ActiveModel designing belongs_to and has_many with single class


I need some help modeling my models and controller. Here is what I want to achieve:

I want to have a devise user named User (as usual) and a second model named Project. A Project should belong to a single User and at the same time should have many participants. The participants in a project should also be users (with devise registration/login) but the user, that created the project should not be able to participate. So far, so good. Here comes the tricky part: In my controller I want to be able to write:

def participate
  p = Project.find(id: params[:id])
  p.participants << current_user unless p.participants.includes?(current_user) && !p.user_id.equal(current_user.id)
  if p.save
    redirect_back
  else
    render :project
  end
end

This doesn't work because p.participants is not an array and the query (I tried it in rails console) does not check my n:m table. Here is my current model setup:

class Project < ApplicationRecord
  before_validation :set_uuid, on: :create
  validates :id, presence: true

  belongs_to :user
  has_and_belongs_to_many :participants, class_name: "User"
end
class User < ApplicationRecord
  before_validation :set_uuid, on: :create
  validates :id, presence: true

  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_and_belongs_to_many :projects
end

Finally my migrations:

class CreateProjects < ActiveRecord::Migration[6.0]
  def change
    create_table :projects, id: false do |t|
      t.string :id, limit: 36, primary_key: true
      t.string :title
      t.belongs_to :user, index: true, foreign_key: true, type: :uuid
      t.datetime :published_at

      t.timestamps
    end
  end
end
class CreateJoinTableProjectsUsers < ActiveRecord::Migration[6.0]
  def change
    create_join_table :users, :projects do |t|
      t.index :project_id
      t.index :user_id
    end
  end
end

Solution

  • It is better to use has_many: through instead of has_and_belongs_to_many. This allows you to write cleaner code for validation.

    1. Remove has_and_belongs_to_many from User and Project models
    2. Add has_many :through to User and Project models

      rails g model UserProject user:references project:references
      rails db:migrate
      
      class User < ApplicationRecord
        ..
        has_many :user_projects
        has_many :projects, through: :user_projects
        ..
      end
      
      class Project < ApplicationRecord
        ..
        has_many :user_projects
        has_many :participants, through: :user_projects, source: 'user'
        ..
      end
      
      class UserProject < ApplicationRecord
        belongs_to :user
        belongs_to :project
      end
      
    3. Add validation to UserProject model

      class UserProject < ApplicationRecord
        belongs_to :user
        belongs_to :project
      
        validate :check_participant
      
        private
        def check_participant
          return if project.participants.pluck(:id).exclude?(user.id) && project.user != user
          errors.add(:base, 'You cannot be participant')
        end
      end
      
    4. Update participate method

      def participate
        p = Project.find(id: params[:id])
        begin
          p.participants << current_user
          redirect_back
        rescue ActiveRecord::RecordInvalid => invalid
          puts invalid.record.errors
          render :project
        end
      end