DEV Community

Cover image for DGraph Advanced Data Modeling: Part 4 - Likes, Bookmarks, and Votes
Jonathan Gamble
Jonathan Gamble

Posted on • Updated on

DGraph Advanced Data Modeling: Part 4 - Likes, Bookmarks, and Votes

Usually when you want to like something, bookmark something, or vote on something, you have a similar data model. You do not want someone to have multiple likes on the same item, in this case.

We think of a hearts data model like the following:

SQL

Posts Table

id name ...

User Table

id name ...

Posts_Likes Table (Junction)

posts_fk users_fk
  • You have a foreign key constrain so that the junction table must always match the primary keys
  • You enable Cascade Delete on both tables
  • You use a COUNT WHERE clause to count the likes on a certain table

noSQL

Posts --> PostID --> Hearts --> UserID
Enter fullscreen mode Exit fullscreen mode

OR

Users --> UserID --> Hearts --> PostID
Enter fullscreen mode Exit fullscreen mode

OR

Hearts --> PostID__UserID
Enter fullscreen mode Exit fullscreen mode
  • You have a Rule that checks this to make sure it matches the postID and userID.
  • The ID__ID makes sure you do not have duplicate items
  • In Firebase / Firestore you may have to keep up with your counts using increments and decrements, and use rules to enforce this. Mongo will aggregate some of this for you.
  • You must create a custom lambda trigger (or firebase function) that will delete all heart documents when the post is deleted (if that is your functionality)

But in a Graph Database, it is much much cleaner:

type User {
  username: String!
  email: String!
  likedPosts: [Post] @hasInverse(field: likes)
  ...
}
type Post {
  id: ID!
  name: String!
  likes: [User]
  ...
}
Enter fullscreen mode Exit fullscreen mode
  • You add a user link to the likes node, and you remove it.
  • Simply count the number of 'links' in Post.likes Where ID is that post
  • You can query likedPosts from the user end, or likes from the post end.
  • The @hasInverse will enforce both edges, liked and likedPost, will get deleted with the post or user

Meta Information

  • SQL - store createdAt in the Junction Table
  • noSQL - store createdAt in the 'hearts' document
  • graphdb
    1. store createdAt in a facet
    2. store createdAt in a nested node

The data model above assumes you do not want the createdAt information, but if you do, you would model it this way:

type User {
  username: String!
  email: String!
  likedPosts: [Like] @hasInverse(field: user)
  ...
}
type Post {
  id: ID!
  name: String!
  likes: [Like] @hasInverse(field: post)
  ...
}
type Like {
  id: String! @id
  post: Post!
  user: User!
}
Enter fullscreen mode Exit fullscreen mode
  • Here you could copy the same noSQL pattern and enforce: id = post.id + '__' + user.id in a custom mutation

Security

I would recommend you use the first method unless you need meta information. If you don't use GraphQL, also use the first method, and store the createdAt information in facets. Currently, you can't query facets in GraphQL yet.

Both versions also will need a custom mutation in order for someone to heart a post, until Field Level Auth becomes available in the future to prevent users from editing your post as well.

Your custom mutation could have a toggle version like so:

async function toggleLike({ args, dql, authHeader }: any) {

    const claimsBase64 = authHeader.value.split(".")[1];
    const claims = JSON.parse(atob(claimsBase64));
    const postID = args.id;

    const q = `
    upsert {
        query {
            q1(func: eq(User.email, "${claims.email}")) {
                User as uid
            }
            q2(func: uid("${postID}")) {
                uid
                Post.likes
                @filter(eq(User.email, "${claims.email}")) {
                    r as uid
                    User.email
                }
            }
        }
        mutation @if(eq(len(r), 1)) {
            delete {
                <${postID}> <Post.likes> uid(User) .
                uid(User) <User.likedPosts> <${postID}> .
            }
        }
        mutation @if(eq(len(r), 0)) {
            set {
                <${postID}> <Post.likes> uid(User) .
                uid(User) <User.likedPosts> <${postID}> .
            }
        }
    }`;

    const m = await dql.mutate(q);
    return featureID;
}

(self as any).addGraphQLResolvers({
    "Mutation.toggleLike": toggleLike,
});
Enter fullscreen mode Exit fullscreen mode

If you prefer to separate like / unlike, you could just run a basic JSON DQL mutation in the lambda(s) for each:

like

{
  "uid": postID,
  "Post.likes": {
    "uid": userID,
    "User.likedPost": {
      "uid": postID
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

unlike

{
  "delete": {
    "uid": postID,
    "Post.likes": {
      "uid": userID,
      "User.likedPost": {
        "uid": postID
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If you need the second version, with the extra edge for meta information, join the unofficial dgraph discord server and ask!

However, if security is not a worry, you could stick with the trusty graphQL and avoid the lambda completely:

mutation {
  updatePost(input: {
    filter: {
      id: "0x2"
    },
    set: {
      "likes": "0x28"
    }
  }) { id }
}
Enter fullscreen mode Exit fullscreen mode

But those of us who care about security, will have an auth-rule to make sure our post is secure, and create a custom lambda mutation to allow any logged in user to securely like, bookmark, or vote on a post, tweet, or other item.

That is, until DGraph implements more security later in 2022.

Next up, Advanced @auth security...

J

Discussion (0)