Search code examples
ruby-on-railsfacebook-graph-apineo4jkoala-gemneo4j.rb

Creating Relationships in Neo4J model with after_save


So I apologize for how noobish these questions may seem. I'm new to rails and as a first task I also brought in Neo4J as it seemed like the best fit if I grow the project.

I'll explain the flow of actions then show some example code. I'm trying to add in step 3-5 now.

  1. User logs in via FB
  2. The first login creates a user node. If the user exist, it simply retrieves that user+node
  3. After the user node is created, the koala gem is used to access the FB Graph API
  4. Retrieves friendlist of each friend using the app.
  5. Go through each friend and add a two way friendship relationship between the two users

As 3-5 only needs to happen when the user first joins, I thought I could do this in a method associated with after_save callback. There is a flaw to this logic though as I will need to update the user at some point with additional attributes and it will call after_save again. Can I prevent this from occurring with update?

SessionsController for reference

  def create
    user = User.from_omniauth(env["omniauth.auth"])
    session[:user_id] = user.id  
    redirect_to root_url
  end

  def destroy
    session.delete(:user_id)
    redirect_to root_path
  end

So in my user.rb I have something like this

 has_many :both, :friendships

  after_save :check_friends


  def self.from_omniauth(auth)
    @user = User.where(auth.slice(:provider, :uid)).first

    unless @user
      @user = User.new
      # assign a bunch of attributes to @user

      @user.save!
    end
    return @user
  end

  def facebook
    @facebook ||= Koala::Facebook::API.new(oauth_token)

    block_given? ? yield(@facebook) : @facebook
      rescue Koala::Facebook::APIError => e
      logger.info e.to_s
      nil
  end

  def friends_count
    facebook { |fb| fb.get_connection("me", "friends", summary: {}) }
  end

  def check_friends(friendships)
    facebook.get_connection("me", "friends").each do |friend|
      friend_id = friend["id"]
      friend_node = User.where(friend_id)
      Friendship.create_friendship(user,friend_node)
      return true
    end
  end

friendship.rb

  from_class User
  to_class   User
  type 'friendship'

  def self.create_friendship(user,friend_node)
    friendship = Friendship.create(from_node: user, to_node: friend_node)
  end   

I'm not sure if I'm on the right track with how to create a relationship node. As I just created @user, how do I incorporate that into my check_friends method and retrieve the user and friend node so properly so I can link the two together.

Right now it doesn't know that user and friend_user are nodes

If you see other bad code practice, please let me know!

In advance: Thanks for the help @subvertallchris. I'm sure you will be answering lots of my questions like this one.


Solution

  • This is a really great question! I think that you're on the right track but there are a few things you can change.

    First, you need to adjust that has_many method. Your associations always need to terminate at a node, not ActiveRel classes, so you need to rewrite that as something like this:

    has_many :both, :friends, model_class: 'User', rel_class: 'Friendship'

    You'll run into some problems otherwise.

    You may want to consider renaming your relationship type in the interest of Neo4j stylistic consistency. I have a lot of bad examples out there, so sorry if I gave you bad ideas. FRIENDS_WITH would be a better relationship name.

    As for handling your big problem, there's a lot you can do here.

    EDIT! Crap, I forgot the most important part! Ditch that after_save callback and make the load existing/create new user behavior two methods.

    class SessionsController < ApplicationController
      def create
        user = User.from_omniauth(env["omniauth.auth"])
        @user = user.nil? ? User.create_from_omniauth(env["omniauth.auth"]) : user
        session[:user_id] = @user.id
        redirect_to root_url
      end
    
      def destroy
        session.delete(:user_id)
        redirect_to root_path
      end
    end
    
    
    class User
      include Neo4j::ActiveNode
      # lots of other properties
      has_many :both, :friends, model_class: 'User', rel_class: 'Friendship'
    
      def self.from_omniauth(auth)
        User.where(auth.slice(:provider, :uid)).limit(1).first
      end
    
      def self.create_from_omniauth(auth)
        user = User.new
        # assign a bunch of attributes to user
        if user.save!
          user.check_friends
        else
          # raise an error -- your user was neither found nor created
        end
        user
      end
    
      # more stuff
    end
    

    That'll solve your problem with getting it started. You may want to wrap the whole thing in a transaction, so read about that in the wiki.

    But we're not done. Let's look at your original check_friends:

    def check_friends(friendships)
      facebook.get_connection("me", "friends").each do |friend|
        friend_id = friend["id"]
        friend_node = User.where(friend_id)
        Friendship.create_friendship(user,friend_node)
        return true
      end
    end
    

    You're not actually passing it an argument, so get rid of that. Also, if you know you're only looking for a single node, use find_by. I'm going to assume there's a facebook_id property on each user.

    def check_friends
      facebook.get_connection("me", "friends").each do |friend|
        friend_node = User.find_by(facebook_id: friend["id"])
        Friendship.create_friendship(user,friend_node) unless friend_node.blank?
      end
    end
    

    The create_friendship method should should return true or false, so just make that the last statement of the method does that and you can return whatever it returns. That's as easy as this:

    def self.create_friendship(user, friend_node)
      Friendship.new(from_node: user, to_node: friend_node).save
    end
    

    create does not return true or false, it returns the resultant object, so chaining save to your new object will get you what you want. You don't need to set a variable there unless you plan on using it more within the method.

    At this point, you can easily add an after_create callback to your ActiveRel model that will do something on from_node, which is always the User you just created. You can update the user's properties however you need to from there. Controlling this sort of behavior is exactly why ActiveRel exists.

    I'd probably rework it a bit more, still. Start by moving your facebook stuff into a module. It'll keep your User model cleaner and more focused.

    # models/concerns/facebook.rb
    
    module Facebook
      extend ActiveSupport::Concern
    
      def facebook
        @facebook ||= Koala::Facebook::API.new(oauth_token)
    
        block_given? ? yield(@facebook) : @facebook
          rescue Koala::Facebook::APIError => e
          logger.info e.to_s
          nil
      end
    
      def friends_count
        facebook { |fb| fb.get_connection("me", "friends", summary: {}) }
      end
    end
    
    # now back in User...
    
    class User
      include Neo4j::ActiveNode
      include Facebook
      # more code...
    end
    

    It's really easy for your models to become these messy grab bags. A lot of blogs will encourage this. Fight the urge!

    This should be a good start. Let me know if you have any questions or if I screwed anything up, there's a lot of code and it's possible I may need to clarify or tweak some of it. Hope it helps, though.