6

Getting and Creating Likes with Neo4j | Max De Marzi

 3 years ago
source link: https://maxdemarzi.com/2020/04/22/getting-and-creating-likes-with-neo4j/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Getting and Creating Likes with Neo4j

graphs-and-pyramids-3.png?w=580&h=145

In the last blog post, we created the Schema of our application and that was pretty dry stuff. It doesn’t get much better yet, so feel free to go do something more useful with your time, but before you go let me ask you a question. Did you ever have someone you really liked, I’m talking about the kind of person you thought about constantly, who made your heart skip a beat. The kind of person you knew was “the one“. But…

They did not return those feelings. You couldn’t understand how they could simply reject you. Could you not make it any more clear to them? Did it ever make you sad and confused? Speaking hypothetically of course, I would not be so pathetic for that to have ever happened to me– I mean you would not be so pathetic right? Right? Yeah. That’s right… but do you wish you could get them to like you, to somehow stop liking them or maybe for you to like someone else? Well, you’re in luck because today we’re going to build the “Likes” functionality of our social network, so we can get, create and remove likes unlike back then when we were really hung up on someone.

zero-likes.png?w=580&h=305

We will start off with a likes package and create a Likes class in there. Just like before you want to grab the database and the log from the context so we can use them in our stored procedures. You can follow along with the code below in pieces or see the likes class all together on the repository.

public class Likes {
@Context
public GraphDatabaseService db;
@Context
public Log log;

Let’s do the “get” method first. We’ll call it “me.tucu.likes.get” and set the mode to READ since it will not be modifying any data, just fetching records. We need four parameters. The User whose likes we want, how many of them, since when so we can do pagination, and who is asking for this information so we can provide them some personalized results.

@Procedure(name = "me.tucu.likes.get", mode = Mode.READ)
@Description("CALL me.tucu.likes.get(username, limit, since, username2)")
public Stream<MapResult> getLikes(@Name(value = "username", defaultValue = "") String username,
@Name(value = "limit", defaultValue = "25") Long limit,
@Name(value = "since", defaultValue = "-1") Long since,
@Name(value = "username2", defaultValue = "") String username2) {
ArrayList<Map<String, Object>> results = new ArrayList<>();

Alright, first stupid thing I like to do is make sure the limit is a positive value. I use it in loops sometimes and the last thing I want is an infinite loop, so I make sure it’s greater than zero. Next thing is figuring out the date of when these Likes should start. If they did not pass in a value then from now going backwards, otherwise from whatever value they passed in.

limit =  abs(limit);
ZonedDateTime dateTime;
if (since == -1L) {
dateTime = ZonedDateTime.now(Clock.systemUTC());
} else {
Instant i = Instant.ofEpochSecond(since);
dateTime = ZonedDateTime.ofInstant(i, UTC);
}

You may have noticed that I’m using a Long to represent “since” in Epoch Seconds instead of using a ZonedDateTime directly… that’s because we can’t use dates in stored procedures as parameters. Maybe one day they will fix this omission, but it’s not that big of a deal.

We need to be in a Transaction to read from the database, so we’ll start one in a try block so it will auto close for us. Let’s go ahead and get user we are interested in. If they aren’t found, we return USER_NOT_FOUND

try (Transaction tx = db.beginTx()) {
Node user = tx.findNode(Labels.User, USERNAME, username);
if (user == null) {
return Stream.of(USER_NOT_FOUND);
}

Just what is that? Well in a “UserExceptions” class I keep things that can go wrong with User requests. One of them is not being able to find the user the stored parameter requested. In this case it’s a MapResult with a single “Error” entry and a message.

public static final MapResult USER_NOT_FOUND = new MapResult(Map.of("Error", "User not Found."));

Just what is that? Well in a MapResult class I keep the following code, stolen from the APOC library.

public class MapResult {
private static final MapResult EMPTY = new MapResult(Collections.emptyMap());
public final Map<String, Object> value;
public static MapResult empty() {
return EMPTY;
}
public  boolean isEmpty() { return value.isEmpty(); }
public MapResult(Map<String, Object> value) {
this.value = value;
}
}

I’ve decided for this application, every stored procedure will return a MapResult. Any errors returned will in the format we saw above, this way in the application I can check the for this “Error” key and know right away if my call succeeded or not and why. This should make it easier to build and more importantly debug the app.

Going back to our getLikes procedure, we next check if a second user was entered as a parameter and if they exist or not.

// If a different user asked for the likes, add a few things
Node user2 = null;
if (!username2.isEmpty() && !username.equals(username2)) {
user2 = tx.findNode(Labels.User, USERNAME, username2);
if (user2 == null) {
return Stream.of(USER_NOT_FOUND);
}
}

So far so good, but pretty bland, alright, let’s get to the Traversal. We want to go from the User out their Likes relationship and if the “time” the relationship was made is before our requested time, then we get the Post which is the node at the end of the relationship and all of its properties. We’re going to want to sort these liked posts, but not by the time the Post was created, but rather by when it was liked. So we’ll add a “liked_time” from the LIKES relationship.

for (Relationship r1: user.getRelationships(Direction.OUTGOING, RelationshipTypes.LIKES)) {
ZonedDateTime time = (ZonedDateTime)r1.getProperty(TIME);
if(time.isBefore(dateTime)) {
Node post = r1.getEndNode();
Map<String, Object> properties = post.getAllProperties();
properties.put(LIKED_TIME, time);

Next we need to know who wrote this post, so we find the author and add some of their properties.

Node author = getAuthor(post);
properties.put(USERNAME, author.getProperty(USERNAME));
properties.put(NAME, author.getProperty(NAME));
properties.put(HASH, author.getProperty(HASH));

You may be wondering what is this getAuthor method? Well remember we are using “dated relationships” for the POSTED_ON_year_month_day, so in order to get who wrote the Post, we need to go from the Post backwards based on the date the Post was created. Alternatively, we could have just saved the node id of the Author or their username as a property of the Post.

public static Node getAuthor(Node post) {
ZonedDateTime time = (ZonedDateTime)post.getProperty(TIME);
RelationshipType original = RelationshipType.withName(POSTED_ON +
time.format(dateFormatter));
return post.getSingleRelationship(original, Direction.INCOMING).getStartNode();
}

Going back to getLikes. We also need the total number of likes, which is easy to get by using getDegree, we’ll also need the number of reposts and if we have a second user whether or not they liked or reposted this post already.

properties.put(LIKES, (long)post.getDegree(RelationshipTypes.LIKES));
properties.put(REPOSTS, getRepostedCount(post));
if (user2 != null) {
properties.put(LIKED, userLikesPost(user2, post));
properties.put(REPOSTED, userRepostedPost(tx, user2, post));
}
results.add(properties);

The getRepostedCount method is an interesting one. In the original Twitter clone, we could just count relationships, but in this one we are creating a tree of reposts in order to build our multi level marketing pyramid for each post, so we can’t just count. We need to first see if the Post is an Advertisement or not. I was tempted to add a second “Advertises” label to the node, but there is a simple way to tell. We can just check to see if the post has a PROMOTES relationship.

public static Long getRepostedCount(Node post) {
long count = 0;
// It's a regular post
if(!post.hasRelationship(RelationshipTypes.PROMOTES)) {
return (long) (post.getDegree(Direction.INCOMING)
- 1 // for the Posted Relationship Type
- post.getDegree(RelationshipTypes.LIKES)
- post.getDegree(RelationshipTypes.REPLIED_TO));
}

If it doesn’t have one, then it’s a regular post and we do just count. But otherwise it’s an advertisement and we have to traverse the incoming REPOSTED relationships. We could have gone down the path of the Traversal API, but this one is pretty simple. We get a list of posts and stick our starting post in there. Then we loop until the list is empty. In the loop we take one post out, traverse its REPOSTED relationships, increment the count, and add any repost nodes to the list. Pretty simple right?

long count = 0;
ArrayList<Node> posts = new ArrayList<>();
posts.add(post);
while (!posts.isEmpty()) {
Node node = posts.remove(0);
for (Relationship rel : node.getRelationships(Direction.INCOMING, RelationshipTypes.REPOSTED)) {
count++;
posts.add(rel.getStartNode());
}
}
return count;

We end the traversal in getLikes method by committing the transaction (get used to doing this or one day you’ll forget on a transaction that writes data and you’ll tear your hair out). Before we return our results, we sort them by the time the post was liked and return the up to some limit.

tx.commit();
}
results.sort(Comparator.comparing(m -> (ZonedDateTime) m.get(LIKED_TIME), reverseOrder()));
return results.stream().limit(limit).map(MapResult::new);

You may be thinking… does this mean we have to traverse all of the LIKES of user even if we just want the last 25. Yes. Yes it does. There is no guaranteed order to the relationships returned by the getRelationships method. You may think what if we used the relationship ids to sort them instead of the time property of the relationship, but Neo4j can reuse the relationship ids of deleted relationships, this idea won’t work. Is this going to cause a performance problem?

I doubt it. Most people will like a few posts, where a few is between zero and 10 thousand. We should be able to traverse that pretty fast. There are some accounts on Twitter that have liked a million posts, but in our social network each Like costs you something, so they won’t be given out so freely.

instagramlike1.jpg?w=580&h=546

Speaking of creating likes, let’s take a look at this procedure. First thing you’ll notice is that the mode is “WRITE” since we will be modifying the graph. It takes the username of the person making the like and the post_id of the Post being liked.

@Procedure(name = "me.tucu.likes.create", mode = Mode.WRITE)
@Description("CALL me.tucu.likes.create(username, post_id)")
public Stream<MapResult> createLikes(@Name(value = "username", defaultValue = "") String username,
@Name(value = "post_id", defaultValue = "-1") Long post_id) {

I’m going to skip parts that are similar to the getLikes method and jump into the middle of this method. We will not allow a User to like a Repost, instead it will send the like to the original post.

post = getOriginalPost(post);
if (userLikesPost(user, post)) {
return Stream.of(ALREADY_LIKES);
}

We do that by following the REPOSTED relationships up the tree in this fashion. Neat right?

public static Node getOriginalPost(Node post) {
while(post.hasRelationship(Direction.OUTGOING, RelationshipTypes.REPOSTED)) {
post = post.getSingleRelationship(RelationshipTypes.REPOSTED, Direction.OUTGOING).getEndNode();
}
return post;
}

When the like gets created we need to move silver or gold from one user to another. To prevent any problems we will lock the users so nobody else can touch them, the lock will be release at the end of the transaction. The thing to note is that the nodes will still be readable, but any procedures trying to acquire a write lock on them will have to wait until ours are released. This means whenever we write a stored procedure to transfer credits, we must get a write lock.

tx.acquireWriteLock(user);
tx.acquireWriteLock(author);

On to the transfer. We first see the balances of our user and make sure they can even make the transfer. They must have a combined positive balance of gold and silver coins.

Long silver = (Long)user.getProperty(SILVER);
Long gold = (Long)user.getProperty(GOLD);
if (gold + silver < 1) {
return Stream.of(INSUFFICIENT_FUNDS);
}

If they have any silver, we will transfer a silver coin from the user to the author of the post. Otherwise we transfer a more valuable gold coin… and we make sure to commit the transaction at the end.

if (silver > 0) {
like.setProperty(SILVER, true);
silver = silver - 1;
user.setProperty(SILVER, silver);
author.setProperty(SILVER, (Long)author.getProperty(SILVER) + 1);
results.put(SILVER, true);
} else {
like.setProperty(GOLD, true);
gold = gold - 1;
user.setProperty(GOLD, gold);
author.setProperty(GOLD, (Long)author.getProperty(GOLD) + 1);
results.put(GOLD, true);
}
tx.commit();

Pretty neat right? Hey remember in the last post where I created an index on Post nodes with two properties? The username and post_id properties? Well I glossed over it, but we actually use it here. When we got the likes, we needed to see if the second user had already reposted the liked post from the first user. It’s simple enough if its a regular post, but if its an advertisement then we try to find a post node with these properties and if we do then we know they did repost it. Check it out:

public static boolean userRepostedPost(Transaction tx, Node user, Node post) {
// It's an advertisement
if(post.hasRelationship(RelationshipTypes.PROMOTES)) {
Long postId = post.getId();
String username = (String)user.getProperty(USERNAME);
ResourceIterator<Node> iterator = tx.findNodes(Labels.Post, USERNAME, username, POST_ID, postId);
return iterator.hasNext();
}
instagram-hiding-likes-button-remove.jpg?w=580&h=356

Ok, so what about what we really came here for, the ability to unlike? We have another procedure that writes to the database:

@Procedure(name = "me.tucu.likes.remove", mode = Mode.WRITE)
@Description("CALL me.tucu.likes.remove(username, post_id)")
public Stream<MapResult> removeLikes(@Name(value = "username", defaultValue = "") String username,
@Name(value = "post_id", defaultValue = "-1") Long post_id) {

I’ll skip the boring parts and show you something interesting. In our app we don’t want people going back and unliking posts just so that they can get their coins back in order to post or like something else. So we’ll have a time limit where the user is allowed to unlike a post (say 1 minute) that they may have liked by accident.

tx.acquireWriteLock(like);
Map<String, Object> likeProperties = like.getAllProperties();
results.put(LIKED_TIME, likeProperties.get(TIME));
if(((ZonedDateTime)results.get(LIKED_TIME))
.isBefore(ZonedDateTime.now().minus(TIMEOUT, ChronoUnit.MINUTES))) {
return Stream.of(UNLIKE_TIMEOUT);
}

Above we took a write lock on the relationship itself and as long as we are within the time window, we will lock the user and author of the post like before.

tx.acquireWriteLock(user);
tx.acquireWriteLock(author);

Next we must refund whatever they paid the first time. So if they paid Silver, they get silver back, if they paid gold, they get gold back. Finally we must delete the relationship, and commit our transaction.

if(likeProperties.containsKey(SILVER)){
user.setProperty(SILVER, 1L + (long)user.getProperty(SILVER));
author.setProperty(SILVER, (Long)author.getProperty(SILVER) - 1L);
results.put(SILVER, true);
} else {
user.setProperty(GOLD, 1L + (long)user.getProperty(GOLD));
author.setProperty(GOLD, (Long)author.getProperty(GOLD) - 1L);
results.put(GOLD, true);
}
like.delete();
tx.commit();

Alright, I think that covers the interesting bits. The source code is online so take a look for the parts I skipped. If you see any glaring holes in my logic or bugs, please leave a comment below, create an issue on the repository or send me a pull request.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK