Chapter 10: Modelling Friendships

Modelling Friendships is under many aspects similar to modelling Hobbies. In this chapter I concentrate on the new or different elements. It is strongly recommended to read the previous chapter first.

  1. Modelling the Friendship

    Making friends is essential for social networking. The main idea behind this type of application is that you create links with other users, and these links are here simply called friendships.

    In the simplest form, you can select a user (for example, from a list of users coming from the same town as you), and request a friendship. The person nominated as your friend may accept or reject your friendship. If it is accepted, you will both appear on each other's list of friends.

    In terms of relationships between the models, you can say that:

    • User has_many Friends

    But also, symmetrically:

    • Friend has_many Users

    Notice, that the friend is just another user. Therefore what we have is simply:

    • User has_many Users

    Unfortunately, this is no progress: you cannot model this type of a relationship within the User model.

    Instead, we will try another approach. We will create a Friendship model and will specify that:

    • User has_many Friendships
    • Friendship belongs_to User
    • Friendship belongs_to Friend (and a Friend is in fact just another User)

    This is something we can model in Rails. Firstly, let's plan the Friendship model. It will contain the following fields:

    user_id
    The user.
    friend_id
    Another user, for simplicity called friend.
    status
    Status of friendship, may be either:
    • pending - when you requested a friendship and are still waiting for the other user to accept or reject,
    • requested - when other user nominated you as a friend, and you can either accept it or reject,
    • accepted - after the friendhip has been accepted;
    • there is no status for rejected - rejected friendships will simply be deleted.

    Therefore, the Friendship model may be generated as follows:

    ruby script/generate model Friendship user_id:integer friend_id:integer status:string

    Just for sure, have a look at the migration file:

    004_create_friendships.rb
    class CreateFriendships < ActiveRecord::Migration
      def self.up
        create_table :friendships do |t|
          t.integer :user_id
          t.integer :friend_id
          t.string :status
    
          t.timestamps
        end
      end
    
      def self.down
        drop_table :friendships
      end
    end
    
    

    and, don't forget to rake:

    rake db:migrate

    Final touches at this stage will include modelling the relationships between the User and Friendship models:

    app/models/user.rb
    class User < ActiveRecord::Base
      has_many :hobby
      has_many :friendship
      ...
    
    app/models/friendship.rb
    class Friendship < ActiveRecord::Base
      belongs_to :user
      belongs_to :friend, :class_name => "User", :foreign_key => "friend_id"
      
      validates_presence_of :user_id, :friend_id
    end
    

    Notice that friend is specially annotated in the Friendship model. The friend's class is User and its corresponding database key (so called foreign key) is friend_id. And now everything is clear.

  2. Listing Friends

    A collection of the user friendships is now available as @user.friendship. Within each friendship, you can access its user as friendship.user, its friend as friendship.friend and, obviously, its status as friendship.status. Once we have modelled everything properly, it's quite clear and easy to use!

    We will now extend the User Hub and the User Profile pages with their lists of friends:

    app/views/user/index.html.erb
    <h1>Hello, <%= @user.full_name %>!</h1>
    
    <h2>Your Details:</h2>
    ...
    
    <h2>Your Hobbies</h2>
    ...
    
    <h2>Your Friends</h2>
    <ol>
    <% @user.friendship.each do |friendship| %>
      <li><%= friendship.friend.full_name %>, <%= friendship.status %></li>
    <% end %>
    </ol>
    

    A similar step should be done with app/views/user/profile.html.erb:

    app/views/user/profile.html.erb
    <h1><%= @user.full_name %>: User Profile</h1>
    
    <h2>Personal Details:</h2>
    ...
    
    <h2>Hobbies</h2>
    ...
    
    <h2>Your Friends</h2>
    <ol>
    <% @user.friendship.each do |friendship| %>
      <li><%= friendship.friend.full_name %>, <%= friendship.status %></li>
    <% end %>
    </ol>
    
  3. Making Friends, modelling

    In the previous section we saw how to list your friends, but we still haven't made any friends! Let's change this.

    To make a new friend you need to create a new friendship with the following data in it:

    • user_id should be set to the logged user,
    • friend_id should be set to the id of the nominated user,
    • status should be set to "pending".

    The newly created friendship that is "pending" for you, is "requested" for someone else. We will therefore, in the same time, create another friendship:

    • user_id should be set to the nominated user,
    • friend_id should be set to you (the currently logged user),
    • status should be set to "requested".

    It is important to notice here that in fact we model a relationship between two users with two frienships, not just one. This is because the friendship model we used is not symmetrical and we need a pair of them to get full symmetry. It is not the only possible solution for this problem, but one of the simplest ones.

    The user has in fact no access to the friendships in which he's registered as a friend (see the User.rb model file above). The complete list of friendships, whether they are pending, required or accepted, is accessible through @user.friendship.

    To hide all the complexity, we will first create a request function in the Friendship model. It's pretty simple:

    app/models/friendship.rb
    class Friendship < ActiveRecord::Base
      belongs_to :user
      belongs_to :friend, :class_name => "User", :foreign_key => "friend_id"
      
      validates_presence_of :user_id, :friend_id
      
      def self.request(user, friend)
        f1 = new(:user => user, :friend => friend, :status => "pending")
        f2 = new(:user => friend, :friend => user, :status => "requested")
        transaction do
          f1.save
          f2.save
        end
      end
    end
    

    First, we create two friendships. Notice that we pass an "unpacked" hash as a parameter to the new function. In most cases so far we had a packed has variable. For example User.new(params[:user]) created a new user using a hash of values passed from a web form. As you can see in the example above, all the values can be provided explicitly.

    In the second phase both friendships are saved in the database, using the save function that we have already used at several occasions. Notice that both instructions are now embraced with a transaction statement. This means that they will compose a database transaction.

    Transaction

    Transaction is a type of a database operation that may be either totally successful or totally rejected. If only one operation is successful, and the other one fails - the database will roll back to the state in which it was before the transaction started.

    Using a transaction guarantees that either both friendships are saved, or none of them. It could be dangerous for the application if one transaction succeeded, and the other failed due to, for example, improper validations. This would lead to lack of consistency in the data (X would be a friend of Y but Y would not be a friend of X).

  4. Making Friends, action!

    Now, when we have a model that implements a request function that requests a new friendship, we can create a new Friendship controller to provide this functionality to the users:

    ruby script/generate controller Friendship

    None of its actions will have any views.

    The action, unfortunately, cannot be named request - due to a clash with a Rails internal name. Instead, we will call it req:

    app/controllers/friendship_controller.rb
    class FriendshipController < ApplicationController
    
      def req
        @user = User.logged_in(session)
        @friend = User.find_by_screen_name(params[:id])
        unless @friend.nil?
          if Friendship.request(@user, @friend)
            flash[:notice] = "Friendship with #{@friend.full_name} requested"
          else
            flash[:notice] = "Friendship with #{@friend.full_name} cannot be requested"
          end
        end
        redirect_to :controller => :user, :action => :index
      end
      
     end
    

    As you can see, it's quite simple now. All the details have been hidden inside the Friendship model.

    You can test your request using a URL like http://localhost:3000/friendship/req/user, where user stands for the required user id (modify this address if you have used a different name for the controller).

  5. Refining Friendship Request

    In this section we will provide some user interface so that, finally, users can really make friends. A simple version may look like this:

    app/views/user/find_by_town.html.erb
    <h1>All People in <%= @town %></h1>
    <ul>
    <% @users.each do |user| %>
      <li>
        <%= link_to user.full_name, 
                    :controller => :user,
                    :action => :profile,
                    :id => user.screen_name %>
        (<%= link_to "request friendship",
                      :controller => :friendship,
                      :action => :req,
                      :id => user.screen_name %>)
      </li>	
    <% end %>
    </ul>
    

    The code above has one serious shortage: it allows to make friends with someone who already is our friend. To overcome this, we will first check if the person is a friend. We will also disable friendships with self (with the same user).

    The details of how to check if two users are friends will be hidden in the model file:

    app/models/friendship.rb
    class Friendship < ActiveRecord::Base
      belongs_to :user
      belongs_to :friend, :class_name => "User", :foreign_key => "friend_id"
      
      validates_presence_of :user_id, :friend_id
      
      def self.are_friends(user, friend)
        return false if user == friend
        return true unless find_by_user_id_and_friend_id(user, friend).nil?
        return true unless find_by_user_id_and_friend_id(friend, user).nil?
        false
      end
      
      def self.request(user, friend)
        return false if are_friends(user, friend)
        return false if user == friend
        f1 = new(:user => user, :friend => friend, :status => "pending")
        f2 = new(:user => friend, :friend => user, :status => "requested")
        transaction do
          f1.save
          f2.save
        end
        return true
      end
    
    end
    

    Above, a are_friends function has been added. It just checks if the two users passed in the parameter list are friends. We also enhanced slightly the request function made previously.

    The final code for find_by_town will now be enhanced with two additional tests:

    • if the user is already a friend, the application just displays "you are friends",
    • if the user listed is the same as the user logged in, the application doesn't display anything,
    • in any other case it dispalys a link to the req action, as previously.
    app/views/user/find_by_town.html.erb
    <h1>All People in <%= @town %></h1>
    <ul>
    <% @users.each do |user| %>
      <li>
        <%= link_to user.full_name, 
                    :controller => :user,
                    :action => :profile,
                    :id => user.screen_name %>
        <% if Friendship.are_friends(User.logged_in(session), user) %>
          (you are friends)
        <% elsif User.logged_in(session) != user %>
          (<%= link_to "request friendship",
                      :controller => :friendship,
                      :action => :req,
                      :id => user.screen_name %>)
        <% end %>
      </li>	
    <% end %>
    </ul>
    
  6. Accepting and Rejecting Friends

    Accepting and rejecting friends is the last bit of functionality that is still missing.

    First, let's add the links to the accept and reject actions, from the User Index page:

    app/views/user/index.html.erb
    <h1>Hello, <%= @user.full_name %>!</h1>
    
    <h2>Your Details:</h2>
    ...
    
    <h2>Your Hobbies</h2>
    ...
    
    <h2>Your Friends</h2>
    <ol>
    <% @user.friendship.each do |friendship| %>
      <li>
        <%= link_to friendship.friend.full_name,
              :controller => :user,
              :action => :profile,
              :id => friendship.friend.screen_name %>, 
        <%= friendship.status %>
        <% if friendship.status == "requested" %>
          (<%= link_to "accept",
              :controller => :friendship,
              :action => :accept,
              :id => friendship.friend.screen_name %> |
          <%= link_to "reject",
              :controller => :friendship,
              :action => :reject,
              :id => friendship.friend.screen_name %>)
        <% end %>
      </li>
    <% end %>
    </ol>
    

    It is by now our standard that all operations concerning the model are defined within the model file. We now add to new functions:

    accept
    This functions simply finds the pair of friendships and changes their status to accepted. Again, a database transaction is used to ensure integrity.
    reject
    This function finds the pair of friendships and destroys both of them. As above, a database transaction is used to ensure integrity.
    app/models/friendship.rb
    class Friendship < ActiveRecord::Base
      ...
    
      def self.accept(user, friend)
        f1 = find_by_user_id_and_friend_id(user, friend)
        f2 = find_by_user_id_and_friend_id(friend, user)
        if f1.nil? or f2.nil?
          return false
        else
          transaction do
            f1.update_attributes(:status => "accepted")
            f2.update_attributes(:status => "accepted")
          end
        end
        return true
      end
      
      def self.reject(user, friend)
        f1 = find_by_user_id_and_friend_id(user, friend)
        f2 = find_by_user_id_and_friend_id(friend, user)
        if f1.nil? or f2.nil?
          return false
        else
          transaction do
            f1.destroy
            f2.destroy
            return true
          end
        end
      end
    
      ...
    

    And here are the controller actions:

    app/controllers/friendship_controller.rb
    class FriendshipController < ApplicationController
    
      ...
      
      def accept
        @user = User.logged_in(session)
        @friend = User.find_by_screen_name(params[:id])
        unless @friend.nil?
          if Friendship.accept(@user, @friend)
            flash[:notice] = "Friendship with #{@friend.full_name} accepted"
          else
            flash[:notice] = "Friendship with #{@friend.full_name} cannot be accepted"
          end
        end
        redirect_to :controller => :user, :action => :index
      end
    
      def reject
        @user = User.logged_in(session)
        @friend = User.find_by_screen_name(params[:id])
        unless @friend.nil?
          if Friendship.reject(@user, @friend)
            flash[:notice] = "Friendship with #{@friend.full_name} rejected"
          else
            flash[:notice] = "Friendship with #{@friend.full_name} cannot be rejected"
          end
        end
        redirect_to :controller => :user, :action => :index
      end
    
      ...
    
  7. Advanced Concepts

    You can ignore this section, it just shows how various collections of related objects can be made within a model.

    app/models/user.rb
    class User < ActiveRecord::Base
      has_many :hobby
      has_many :friendships
      has_many :friends, 
               :through => :friendships,
               :conditions => "status = 'accepted'", 
               :order => :screen_name
    
      has_many :requested_friends, 
               :through => :friendships, 
               :source => :friend,
               :conditions => "status = 'requested'", 
               :order => :created_at
    
      has_many :pending_friends, 
               :through => :friendships, 
               :source => :friend,
               :conditions => "status = 'pending'", 
               :order => :created_at
    

Valid XHTML 1.0 Strict Valid CSS!