guise

Build Status Code Climate

A typical, quick-and-easy role management system involves users and roles tables with a join table between them to determine membership to a role:

class User < ActiveRecord::Base
  has_many :user_roles
  has_many :roles, through: :user_roles
end

class UserRole < ActiveRecord::Bae
  belongs_to :user
  belongs_to :role
end

class Role < ActiveRecord::Base
  has_many :user_roles
  has_many :users, through: :user_roles
end

A problem with this is that in simple setups, application behavior tends to be hard-coded to rely on the existence of a database record representing the role object in the roles table.

guise de-normalizes the above setup by storing the name of the role as a column in what would be the join table between users and roles. The allowed values are limited to the values defined in a declaration on the model that is meant to have different roles.

Given User and Role models where the Role model has a value column.

class User < ActiveRecord::Base
end

class Role < ActiveRecord::Base
end

By adding the following method call to has_guises to User and guise_for to Role:

class User < ActiveRecord::Base
  has_guises :DeskWorker, :MailForwarder, association: :roles, attribute: :value
end

class Role < ActiveRecord::Base
  guise_for :User
end

The equivalent associations, model scopes and validations are configured:

class User < ActiveRecord::Base
  has_many :roles

  scope :desk_workers, -> { joins(:roles).where(roles: { value: "DeskWorker" }) }
  scope :mail_forwarders, -> { joins(:roles).where(roles: { value: "MailForwarder" }) }

  def has_role?(value)
    roles.detect { |role| role.value == value }
  end

  def has_roles?(*values)
    values.all? { |value| has_role?(value)
  end

  def has_any_roles?(*values)
    values.any? { |value| has_role?(value)
  end

  def desk_worker?
    has_role?("DeskWorker")
  end

  def mail_forwarder?
    has_role?("MailForwarder")
  end
end

class Role < ActiveRecord::Base
  belongs_to :user

  scope :desk_workers, -> { where(value: "DeskWorker") }
  scope :mail_forwarders, -> { where(value: "MailForwarder") }

  validates(
    :value,
    presence: true,
    uniqueness: { scope: :user_id },
    inclusion: { in: %w( DeskWorker MailForwarder ) }
  )
end

This allows filtering users by role / type and assigning records a role without requiring an existing record in the database. The predicate methods can be used for permissions / authorization.

Installation

Add this line to your application's Gemfile:

gem 'guise'

Then execute:

$ bundle

Or install it yourself as:

$ gem install guise

Usage

Create a table to store your type information:

rails generate model role user:references value:string:uniq
rake db:migrate

It is recommended to add an index on the foreign key and guise attribute. In this case the columns are user_id and value.

Then add has_guises to your model. This will setup the has_many association for you. It requires the name of the association and name of the column that the subclass name will be stored in.

class User < ActiveRecord::Base
  has_guises :DeskWorker, :MailForwarder, association: :roles, attribute: :value
end

This adds the following methods to the User class:

  • :desk_workers and :mail_forwarders model scopes.
  • :has_guise? that checks if a user is a particular type.
  • :desk_worker?, :mail_forwarder that proxy to :has_guise?.
  • :has_guises? that checks if a user has records for all the types supplied.
  • :has_any_guises? that checks if a user has records for any of the types supplied.

To configure the other end of the association, add guise_for:

class UserRole < ActiveRecord::Base
  guise_for :User
end

This method does the following:

  • Sets up belongs_to association and accepts the standard options.
  • Validates the column storing the name of the guise in the list supplied is unique to the resource it belongs to and is one of the provided names.

Role Subclasses

If using User.<guise_scope> is too tedious, it is possible to setup subclasses to represent each value referenced in has_guises using the guise_of method:

class DeskWorker < User
  guise_of :User
end

This is equivalent to the following:

class DeskWorker < User
  default_scope -> { joins(:roles).where(roles: { value: 'DeskWorker'}) }

  after_initialize do
    self.guises.build(value: 'DeskWorker')
  end
end

To scope the association class to a guise, use scoped_guise_for. The name of the class must be <guise_value><association_class_name> (i.e. the guise it represents combined with the name of the parent class.

class DeskWorkerUserRole < UserRole
  scoped_guise_for :User
end

This sets up the class as follows:

class DeskWorkerUserRole < UserRole
  default_scope -> { where(value: "DeskWorker") }

  after_initialize do
    self.value = "DeskWorker"
  end
end

Customization

If the association doesn't standard association assumptions made by activerecord, you can pass in the options for has_many into has_guises. The same applies to guise_for with the addition that you can specify not to validate attributes.

class Person < ActiveRecord::Base
  has_guises :Admin, :Engineer,
             association: :positions,
             attribute: :rank,
             foreign_key: :employee_id,
             class_name: :JobTitle
end

class JobTitle < ActiveRecord::Base
  guise_for :Person,
            foreign_key: :employee_id,
            validate: false # skip setting up validations
end