Designing Feed Relationships with Graph Databases (Full Stack TigerGraph Part 2)

Designing Feed Relationships with Graph Databases (Full Stack TigerGraph Part 2)

This article is Part 2 of a series of articles on integrating or using TigerGraph in a Full Stack application. Read Part 1 here: https://medium.com/@ramapitchala/full-stack-tigergraph-part-1-d70718111051. Follow the Author on LinkedIn and Medium.

Introduction

In my previous article (Part 1), I introduced how one can integrate TigerGraph into a Full Stack application using a web server which handles the client requests.

In this article, I will mention one use case of TigerGraph when it comes to storing interactions such as likes or comments in a Blog/Post.

I understand that there may be multiple ways of creating a blog and storing the data that may be more optimal. I simply want to showcase how it can be done, since I am using this same model in my own project.

Suppose in a small application there are entities User, Post, and Comment. These following entities can interact with each other in several ways. For example, a User can create a Post, a User can like a Post, User can comment and a User can like a Comment. In addition, Post and Comment can also have child comments. Suppose these interactions are thought of as Vertices with Edges.

It is intuitive how Graph databases can represent these relationships. If you want to follow along, now is the time to create a TigerGraph Cloud Instance as well as a web server to interact with and model this data. You can easily walk through Part 1 and learn how to create both the TigerGraph Cloud Instance and the NodeJS + Express web server.

Disclaimer: Although you are free to add fields as you see fit, I will not be storing the actual content of the Posts or Comments in TigerGraph. In the project that I mentioned earlier, I am actually storing the Comment and Post data in Cloud Firestore. We are using TigerGraph to store the relationships between Posts, Comments and Users.

Our vertices will look as follows:

Post:

Comment:

The comments attribute serves as an aggregator on the vertices. Suppose we need information about the child comments on both the Post and Comment vertex in the metadata. Depending on the scale, it would be more efficient to store an aggregator and use it to find the information about the number of child comments. However, we must increment comments of the parent by 1 whenever a new child comment is created.

User:

I did not place any attributes on the edges for now. You are free to add attributes that are beneficial to your case. Now that we have our schema ready to go, let’s save our changes and move onto writing the queries.

Let’s write a query to create a new post based on the incoming post id (post) and by the author (user). Let’s also do the same for comments. If a new comment is created as either a response to a Post (createCommentsOnPost) or as a response to a Comment(createReplyToComment), we must increment the attribute comments of the parent by 1.

CREATE QUERY createPost(Vertex<User> user, String post) FOR GRAPH MyGraph { 
  Insert Into Post(PRIMARY_ID, comments) Values(post, 0);
  Insert Into CREATES_POST(From, To) Values(user User, post Post);
}
CREATE QUERY createCommentOnPost(Vertex<User> user, Vertex<Post> parent, String comment) FOR GRAPH MyGraph { 
  parent.comments = parent.comments + 1;
  Insert Into Comment(PRIMARY_ID, comments) Values(comment, 0);
  Insert Into IS_CHILD_OF_POST(From, To) Values(comment Comment,      parent Post);
  Insert Into CREATES_COMMENT(From, To) Values(user User, comment Comment);
}
CREATE QUERY createReplyOnComment(Vertex<User> user, Vertex<Comment> parent, String comment) FOR GRAPH MyGraph { 
  parent.comments = parent.comments + 1;
  Insert Into Comment(PRIMARY_ID, comments) Values(comment, 0);
  Insert Into IS_CHILD_OF_COMMENT(From, To) Values(comment Comment, parent Comment);
  Insert Into CREATES_COMMENT(From, To) Values(user User, comment Comment);
}

Now, let’s write some queries to handle liking and unliking Posts and Comments.

CREATE QUERY toggleLikesOnComment(Vertex<Comment> comment, Vertex<User> user, String action) FOR GRAPH MyGraph { 
  if action == "LIKE" then
   Insert Into LIKES_COMMENT(From, To) Values(user User, comment   Comment);
  else
   Start = {comment};
   p = Select s from Start:s -(LIKES_COMMENT:e)-> User:tgt
       Where tgt.id == user.id
       Accum delete(e);
 end;
}
CREATE QUERY toggleLikesOnPost(Vertex<Post> post, Vertex<User> user, String action) FOR GRAPH MyGraph { 
  if action == "LIKE" then
   Insert Into LIKES_POST(From, To) Values(user User, post Post);
 else
   Start = {post};
   p = Select s from Start:s -(LIKES_POST:e)-> User:tgt
       Where tgt.id == user.id
       Accum delete(e);
 end;
}

Finally, we need to write the queries that provide clients with metadata for the Posts and Comments. For posts, the query takes in a set of Post ids as a parameter. In response, the query prints metadata information such as the number of child comments on a Post and an array of ids of all the Users who like the Post. The same is replicated for the Comments.

CREATE QUERY getPostMetadata(Set<Vertex<Post>> posts) FOR GRAPH MyGraph { 
  SetAccum<String> @likers;
  start = posts;
 pos = Select s From start:s -(LIKES_POST)-> User:tgt
           Accum s.@likers += tgt.id;
 
 Print pos as Posts;
}
CREATE QUERY getCommentMetadata(Set<Vertex<Comment>> comments) FOR GRAPH MyGraph { 
 SetAccum<String> @likers;
  start = comments;
 com = Select s From start:s -(LIKES_COMMENT)-> User:tgt
           Accum s.@likers += tgt.id;
 
 Print com as Comments;
}

Now, that we have our queries, let’s now focus on setting up our Web server. The Execution section of Part 1 navigates through this process. On this project, however, there are many more endpoints to write.

The config file in my directory contains an object with the TigerGraph cloud instance url and the token.

const express = require("express");
const app = express();
const config = require("./config");


app.use(express.json()); 

/*
All the parameters to the endpoints below will simply be passed as a object in the body.
This is easily customizable.
*/
app.get("/comments", async (req, res)=>{
    try{
        let body = req.body;
        let url = config.url + "getCommentMetadata";
        let response = await _makeCall(url, body);
        res.status(200).send(response);
    }
    catch(error){
        handleError(res, error.message);
    }
});

app.get("/posts", async (req, res) => {
    try{
        let body = req.body;
        let url = config.url + "getPostMetadata";
        let response = await _makeCall(url, body);
        res.status(200).send(response);
    }   
    catch(error){
        handleError(res, error.message);
    }
});

app.post("/toggle-like-post", async (req, res) => {
    try{
        let body = req.body;
        let url = config.url + "toggleLikesOnPost";
        let response = await _makeCall(url, body);
        res.status(200).send(response);
    }
    catch(error){
        handleError(res, error.message);
    }
});

app.post("/toggle-like-comment", async (req, res) =>{
    try{
        let body = req.body;
        let url = config.url + "toggleLikesOnComment";
        let response = await _makeCall(url, body);
        res.status(200).send(response);
    }
    catch(error){
        handleError(res, error.message);
    }
});

app.post("/create-child", async (req, res) => {
    try{    
        let body = req.body;
        let url = config.url;
        if("post" in body){
            url += "createCommentOnPost";
        }
        else{
            url += "createReplyOnComment";
        }
        let response = await _makeCall(url, body);
        res.status(200).send(response);
    }   
    catch(error){
        handleError(res, error.message);
    }
});

app.post("/create-post", async (req, res)=>{
    try{
        let body = req.body;
        let url = config.url + "createPost";
        let response = await _makeCall(url, body);
        res.status(200).send(response);
    }
    catch(error){
        handleError(res, error.message);
    }

});

/*
This is where the magic happens. Each of the end points will pass in a query url
with an object of parameters. This function will construct the url and make the call.
*/
async function _makeCall(url, params){
    try{
        const keys = Object.keys(params);
        if (keys.length > 0) {
          url += "?"
          for (let index = 0; index < keys.length; index++) {
            const key = keys[index];
            if (Array.isArray(params[key])) { //It was an array of values
              let heading = `${key}=`;
              if (index > 0) {
                heading = `&${key}=`;
              }
              url += heading;
              url += (params[key]).join(`&${key}=`);
            } else { //It was just a single value
              if (index === 0) {
                url += key + "=" + params[key]
              } else {
                url += "&" + key + "=" + params[key]
              }
            }
          }
        }
        const token = config.token;
        const response = await fetch(url, {
                method: 'GET',
                headers: {
                    'Authorization': 'Bearer ' + token,
                    'Accept': 'application/json',
                    'Content-Type': 'application/json',
                }
        });
        const json = await response.json();
        if(json.error){
            throw Error("TigerGraphError:" + json.message);
        }
        return json;  
    }
    catch(error){
        throw Error(error.message);
    }
}

function handleError(res, errorMessage){
    if(errorMessage.indexOf("TigerGraphError") > 0){
        res.status(500).send({error:true, message:errorMessage})
    }
    else{
        res.status(400).send({error:true, message:errorMessage})
    }
}

Here are examples on how calling the endpoints would look like on the JavaScript client side:

import fetch from "node-fetch";


const url = "http://localhost:5000/"

function getComments(commentIds){
    let requestUrl = url + "comments";
    let response = await fetch(requestUrl, 
        {
            body: JSON.stringify(
                {comments: commentIds}
            )
        });
    let json = await response.json();
    if(json.error){
        throw Error("Failed to retrieve the comments");
    }
    return json;
}

function getPosts(postIds){
    let requestUrl = url + "posts";
    let response = await fetch(requestUrl, 
        {
            body: JSON.stringify(
                {posts: postIds}
            )
        });
    let json = await response.json();
    if(json.error){
        throw Error("Failed to retrieve the posts");
    }
    return json;
}

function toggleLikeOnPost(post, user, action){
        let requestUrl = url + "toggle-like-post";
        let response = await fetch(requestUrl, 
            {
                method: "POST",
                body: JSON.stringify(
                    {post: post, user:user, action:action}
                )
            });
        let json = await response.json();
        if(json.error){
            throw Error("Failed to toggle like on post");
        }
        return json;
}

function toggleLikeOnComment(comment, user, action){
    let requestUrl = url + "toggle-like-comment";
    let response = await fetch(requestUrl, 
        {
            method: "POST",
            body: JSON.stringify(
                {comment: comment, user:user, action:action}
            )
        });
    let json = await response.json();
    if(json.error){
        throw Error("Failed to toggle like on comment");
    }
    return json;
}

function createPost(user, post){
    let requestUrl = url + "create-post";
    let response = await fetch(requestUrl, 
        {
            method:"POST",
            body: JSON.stringify(
                {user:user, post:post}
            )
        });
    let json = await response.json();
    if(json.error){
        throw Error("Failed to create post");
    }
    return json;
}

function createChild(user, parent, comment, type){
    let requestUrl = url + "create-child";
    let response = await fetch(requestUrl, 
        {
            method: "POST",
            body: JSON.stringify(
                {user:user, parent:parent, comment:comment,type:type.toUpperCase()}
            )
        });
    let json = await response.json();
    if(json.error){
        throw Error("Failed to create comment");
    }
    return json;
}

These end points do not have to be limited to just making calls to TigerGraph. In fact, one of the benefits with the approach of having a web server to handle client requests is that multiple services can converge, abstracted from the client. For example, perhaps in the /create-post endpoint, I could insert in a new Post document in Firestore, or I could the use Twilio Api to notify a user if they were mentioned in the Post.

Disadvantages

Maintenance: If a new TigerGraph instance is spun up, then an another database must be maintained.In my case, calls must be made to both Firestore when retrieving comments or posts, creating comments or posts as well deleting.

Advantages

Insights: The core relationship data between the entities will already be stored on the Graph itself, making various algorithms for perhaps finding Community groups between users or Influencers readily available.

Conclusion

More edges, vertices, and attributes can be added to schema to improve its design. What I have showed you today is simply a starting point. Please let me know if you liked or disliked some part of the article and how I can improve. Thank you very much for reading!