ruby-on-railsactiverecordpolymorphic-associationsmulti-table-inheritance

has_one through and polymorphic associations over multi-table inheritance


In the project i'm currently developing under rails 4.0.0beta1, i had the need for a user based authentication in which each user could be linked to an entity. I'm kinda new to rails and had some troubles doing so.

The model is as following:

class User < ActiveRecord::Base
end

class Agency < ActiveRecord::Base
end

class Client < ActiveRecord::Base
  belongs_to :agency
end

What i need is for a user to be able to link to either an agency or a client but not both (those two are what i'll be calling entities). It can have no link at all and at most one link.

First thing i looked for was how to do Mutli-Table inheritance (MTI) in rails. But some things blocked me:

So i looked for another solution and i found polymorphic associations.

I've be on this since yesterday and took some time to make it work even with the help of Rails polymorphic has_many :through and ActiveRecord, has_many :through, and Polymorphic Associations

I managed to make the examples from the question above work but it took a while and i finally have two problems:

  1. How to transform the relations in user into a has_one association and be able to access "blindly" the linked entity ?
  2. How to set a constraint so that no user can have more than one entity ?
  3. Is there a better way to do what i want ?

Solution

  • Here's a fully working example:

    The migration file:

    class CreateUserEntities < ActiveRecord::Migration
      def change
        create_table :user_entities do |t|
          t.integer :user_id
          t.references :entity, polymorphic: true
    
          t.timestamps
        end
    
        add_index :user_entities, [:user_id, :entity_id, :entity_type]
      end
    end
    

    The models:

    class User < ActiveRecord::Base
      has_one :user_entity
    
      has_one :client, through: :user_entity, source: :entity, source_type: 'Client'
      has_one :agency, through: :user_entity, source: :entity, source_type: 'Agency'
    
      def entity
        self.user_entity.try(:entity)
      end
    
      def entity=(newEntity)
        self.build_user_entity(entity: newEntity)
      end
    end
    
    class UserEntity < ActiveRecord::Base
      belongs_to :user
      belongs_to :entity, polymorphic: true
    
      validates_uniqueness_of :user
    end
    
    class Client < ActiveRecord::Base
      has_many :user_entities, as: :entity
      has_many :users, through: :user_entities
    end
    
    class Agency < ActiveRecord::Base
      has_many :user_entities, as: :entity
      has_many :users, through: :user_entities
    end
    

    As you can see i added a getter and a setter that i named "entity". That's because has_one :entity, through: :user_entity raises the following error:

    ActiveRecord::HasManyThroughAssociationPolymorphicSourceError: Cannot have a has_many :through association 'User#entity' on the polymorphic object 'Entity#entity' without 'source_type'. Try adding 'source_type: "Entity"' to 'has_many :through' definition.
    

    Finally, here are the tests i set up. I give them so that everyone understands know ho you can set and access data between those objects. i won't be detailing my FactoryGirl models but they're pretty obvious

    require 'test_helper'
    
    class UserEntityTest < ActiveSupport::TestCase
    
      test "access entity from user" do
        usr = FactoryGirl.create(:user_with_client)
    
        assert_instance_of client, usr.user_entity.entity
        assert_instance_of client, usr.entity
        assert_instance_of client, usr.client
      end
    
      test "only right entity is set" do
        usr = FactoryGirl.create(:user_with_client)
    
        assert_instance_of client, usr.client
        assert_nil usr.agency
      end
    
      test "add entity to user using the blind rails method" do
        usr = FactoryGirl.create(:user)
        client = FactoryGirl.create(:client)
    
        usr.build_user_entity(entity: client)
        usr.save!
    
        result = UserEntity.where(user_id: usr.id)
        assert_equal 1, result.size
        assert_equal client.id, result.first.entity_id
      end
    
      test "add entity to user using setter" do
        usr = FactoryGirl.create(:user)
        client = FactoryGirl.create(:client)
    
        usr.client = client
        usr.save!
    
        result = UserEntity.where(user_id: usr.id)
        assert_equal 1, result.size
        assert_equal client.id, result.first.entity_id
      end
    
      test "add entity to user using blind setter" do
        usr = FactoryGirl.create(:user)
        client = FactoryGirl.create(:client)
    
        usr.entity = client
        usr.save!
    
        result = UserEntity.where(user_id: usr.id)
        assert_equal 1, result.size
        assert_equal client.id, result.first.entity_id
      end
    
      test "add user to entity" do
        usr = FactoryGirl.create(:user)
        client = FactoryGirl.create(:client)
    
        client.users << usr
    
        result = UserEntity.where(entity_id: client.id, entity_type: 'client')
    
        assert_equal 1, result.size
        assert_equal usr.id, result.first.user_id
      end
    
      test "only one entity by user" do
    
        usr = FactoryGirl.create(:user)
        client = FactoryGirl.create(:client)
        agency = FactoryGirl.create(:agency)
    
        usr.agency = agency
        usr.client = client
        usr.save!
    
        result = UserEntity.where(user_id: usr.id)
        assert_equal 1, result.size
        assert_equal client.id, result.first.entity_id
    
      end
    
      test "user uniqueness" do
    
        usr = FactoryGirl.create(:user)
        client = FactoryGirl.create(:client)
        agency = FactoryGirl.create(:agency)
    
        UserEntity.create!(user: usr, entity: client)
    
        assert_raise(ActiveRecord::RecordInvalid) {
          UserEntity.create!(user: usr, entity: agency)
        }
    
      end
    
    end
    

    I Hope this can be of some help to someone. I decided to put the whole solution here cause it seems to me like a good one compared to MTI and i think it shouldn't take someone that much time to set something like that up.