#189 Embedded Association
- Download:
- source codeProject Files in Zip (106 KB)
- mp4Full Size H.264 Video (18.2 MB)
- m4vSmaller H.264 Video (13.5 MB)
- webmFull Size VP8 Video (38.8 MB)
- ogvFull Size Theora Video (25.1 MB)
In the previous episode we created a role-based authentication system. On the application’s sign-up page a series of checkboxes allowed a user to assign themselves to one or more roles.
The application has a Role
model in which the roles are defined and a many-to-many relationship between the Role
and User
via an Assignment
model. If we look in the database we’ll see the three existing roles.
>> Role.all +----+-----------+-------------------------+-------------------------+ | id | name | created_at | updated_at | +----+-----------+-------------------------+-------------------------+ | 1 | Admin | 2009-11-16 21:22:59 UTC | 2009-11-16 21:22:59 UTC | | 2 | Moderator | 2009-11-16 21:23:06 UTC | 2009-11-16 21:23:06 UTC | | 3 | Author | 2009-11-16 21:23:16 UTC | 2009-11-16 21:23:16 UTC | +----+-----------+-------------------------+-------------------------+ 3 rows in set
The main problem with this set up is that there is a tight coupling between the roles in the database and the code in the authorization_rules
file that defines the permissions for that role.
role :author do includes :guest has_permission_on :articles, :to => [:new, :create] has_permission_on :articles, :to => [:edit, :update] do if_attribute :user => is { user } end end
With the role and permissions defined this way we can’t make changes to the roles table without also needing to change the Ruby code that defines each role and so the benefits of storing the roles in the database are lost. This episode will show you how to remove this coupling by defining the roles only in the code and not in a database table.
As we’ll no longer have a Role
model the first change we’ll make is to the User
model where we’ll remove the associations with assignments and roles by deleting the following two lines.
has_many :assignments has_many :roles, :through => :assignments
Having done this we’ll have to create the roles somewhere else. As the roles are related to users we’ll define them as a constant in the User
model.
class User < ActiveRecord::Base acts_as_authentic has_many :articles has_many :comments ROLES = %w[admin moderator author] def role_symbols roles.map do |role| role.name.underscore.to_sym end end end
Now we only have to modify the code when we need to alter the roles. But the question remains: how to we associate a user with their roles? As we no longer have a roles table we’re going to have to embed this association within the User
model somehow.
Below we’ll show you two ways of doing this, dependent on the type of association you’re working with.
Embedding a One-to-Many Relationship
Currently our application has a many-to-many relationship between users and roles. We’ll change this to a one-to-many relationship so that a user can only be a member of one role. The role will have to be stored in the users
table but as we’re just storing a single value the role can be stored in a string field. We’ll generate the role field with the following migration.
script/generate migration add_role_to_users role:string
Then we’ll migrate the database to add the new field to the table.
rake db:migrate
The next step is to modify the sign-up form to replace the roles checkboxes with a select menu. To do this we’ll replace this code in /app/views/users/new.html.erb
.
<%= form.label :roles %> <% for role in Role.all %> <%= check_box_tag "user[role_ids][]", role.id, @user.roles.include?(role) %> <%=h role.name %><br /> <% end %>
with this:
<%= form.label :role %> <%= form.collection_select :role, User::ROLES, :to_s, :humanize %>
We’re using collection_select
to generate the select menu. This is usually used with a model, but it works just as well in this scenario. When used with a model we pass it a collection of model objects and the properties for the model that should be used to set the value and text for each option. Instead of that we pass it the array of role names that we defined in the User
model. We set the value for each option by calling :to_s
on each role and the display text by calling :humanize
on each role to prettify it for display. The result of this is a select menu that allows users to select a single role.
As we’re using declarative authorization in our application we’ll also need to make a change to the role_symbols
method in the User
model, changing so that it returns the user’s role converted to a symbol.
def role_symbols [role.to_sym] end
Now when we create a new user their role is stored in the role
field in the users
table.
>> User.last.role => "moderator"
Embedding a Many-to-Many Relationship
So we’ve successfully embedded a one-to-many relationship into a single model but what if we want to keep the ability for users to assign themselves to many roles without having to restore the Role
model? This is a little more difficult to achieve as we have to squeeze multiple values into a single column in the users table.
One solution would be to create a text column called roles
in our users
table and use Rails’ serialize
method to store the user’s roles in that column. This will call to_yaml
on the roles before storing the field in the database. This approach works but it makes it difficult to do things such as finding all of the users in a given role so we’ll try a different approach.
Instead of serializing the roles we’re going to use a bitmask. This way we can store multiple values in a single integer column and still have the ability to retrieve users by their role.
The first thing we’ll do is add a new integer column called roles_mask
to the users
table to store the bitmask value.
script/generate migration add_roles_mask_to_users roles_mask:integer
Then run the migration
rake db:migrate
In the User
model we’ll need to handle the roles_mask
by writing getter and setter methods so that we can convert easily between the bitmask and an array of roles.
def roles=(roles) self.roles_mask = (roles & ROLES).map { |r| 2**ROLES.index(r) }.sum end def roles ROLES.reject { |r| ((roles_mask || 0) & 2**ROLES.index(r)).zero? } end
The setter method takes an array of roles and converts it into a bitmask integer which is assigned to the roles_mask
attribute. The getter loops through each role and returns an array of the roles whose bit is set in the mask. There are plugins available to make bitmasks easier to work with but as we only have one field and have only needed to write a few lines of code to implement this we won’t use one.
Again we’ll need to change the role_symbols
method to work with our altered roles. To do this we’ll take the array of roles and convert each one to a symbol.
def role_symbols roles.map(&:to_sym) end
The final change we need to make is to alter the view again to replace the select menu with checkboxes so that a user can pick a number of roles when they sign up.
<%= form.label :roles %> <% for role in User::ROLES %> <%= check_box_tag "user[roles][]", role, @user.roles.include?(role) %> <%= h role.humanize %> <% end %> <%= hidden_field_tag "user[roles][]"%>
The technique used here to create checkboxes for a many-to-many relationship is similar to the one shown back in episode 17 [watch, read]. The empty square brackets in user[roles][]
mean that the values of the checked checkboxes will be passed as an array to the User
model where the setter method we wrote will convert them to a bitmask value. The last parameter passed to check_box_tag
will check the checkbox if the user is a member of that role. The hidden field ensures that an empty array is passed if no checkboxes are checked.
When we go to the sign-up form now the checkboxes have returned. We’ll sign up a new user and assign them to two roles.
If we look at the newly-added user in the console we can see their roles and the value assigned to their roles_mask
.
>> User.last.roles => ["admin", "author"] >> User.last.roles_mask => 5
Each role has a value twice that of the role next to it, with the lowest-value role having a value of 1. So the value of 5 for our user is calculated from (1*1) + (2*0) + (4*1)
.
Having stored our roles in a single column how do we find all of the users in a given role? Let’s say we want to find all of the users with moderator privileges. We can do this by adding a named scope to our User
model.
named_scope :with_role, lambda { |role| {:conditions => "roles_mask & #{2**ROLES.index(role.to_s)} > 0 "} }
This named scope takes a role as an argument and performs a bitwise operation on it to determine whether a user belongs to that role. We can test this in the console by finding all of the users in the admin role.
>> User.with_role("admin") +----+----------+-------------+-------------+-------------+-------------+------------+ | id | username | email | crypted_... | password... | persiste... | roles_mask | +----+----------+-------------+-------------+-------------+-------------+------------+ | 6 | paul | paul@tes... | cffada11... | FDGoNtM1... | 35a7d8c8... | 5 | +----+----------+-------------+-------------+-------------+-------------+------------+ 1 row in set
This returns only the most recent user as this is the only user we’ve added since we created the roles_mask
field.
If you nned to add extra roles to the application later there’s a potential trap you need to tale care with. As the bitmask is based on the position of the role in the ROLES
array you can only add new roles to the end of this array. If new roles are added at the start or in the middle of the array then any existing users may have their roles changed.
ROLES = %w[admin moderator author editor]
Adding an extra role to the list.
That’s it for this episode. Bitmasking is a powerful technique for embedding a many-to-many relationship within a single integer attribute in a model. That said, it’s only worth applying this method if you have a list of records that don’t belong in a database table. Our list of roles is so tightly bound to the code that it’s pointless storing them outside the code itself as we can’t change a role without having to make changes to the code to define that role’s permissions. If you can see a situation where you could add or alter one of these records without needing to make changes to the code then the traditional many-to-many relationship in the database is still the best approach.