Building a Dating site with Neo4j – Part Four

In the last post, we created a User model, built the login and registration pages, hooked everything up in our front end framework Jooby and got the ball rolling. I’m no designer so I am borrowing a Application Bootstrap Theme and tweaking that as we go along (if you are a designer, pull requests are welcomed). At this stage a ton of it is just mockup, but we will replace it with real functionality. This is what we have so far:

Five years ago, I wrote about Matchmaking with Neo4j in which our users had a list of things they wanted in a potential mate and a list of things they had to offer a potential mate. We are going to call these things Attributes and build them. If we let our users create them, they will make a mess of this list, so I think we’ll have to seed the database with some and maybe grow our list as users request more. Assuming we do that, let’s start with finding an Attribute in the Graph:

 
    public static Node findAttribute(String name, @Context GraphDatabaseService db) {
        if (name == null) { return null; }
        Node attribute = db.findNode(Labels.Attribute, NAME, name);
        if (attribute == null) { throw AttributeExceptions.attributeNotFound; }
        return attribute;
    }

Ok, easy enough. Now what about creating the HAS relationships. We need a POST method since we are creating something, we need the username adding the HAS relationship and the name of the Attribute being added. We also want to check that the user doesn’t already have this attribute, so we don’t create multiple relationships unnecessarily :

 
    @POST
    @Path("/{name}")
    public Response createHas(@PathParam("username") final String username,
                               @PathParam("name") final String name,
                               @Context GraphDatabaseService db) throws IOException {
        Map<String, Object> results;

        try (Transaction tx = db.beginTx()) {
            Node user = Users.findUser(username, db);
            Node attribute = Attributes.findAttribute(name, db);

            if (userHasAttribute(user, attribute)) {
                throw HasExceptions.alreadyHasAttribute;
            }

If all that checks out, we create the HAS relationship, and set a timestamp on it. For our result, we will return the properties of the Attribute plus some additional information. The user HAS this attribute so we will set HAVE to true, we will check if they also WANT this attribute, and lastly we will count how many incoming HAS and WANTS relationship the Attribute has to see how popular it is. One of the nice things about Neo4j is that we store the count of the relationships by direction and type for any node with over 40 relationships right in the first relationship record, making this a very cheap operation compared to other databases.

 
            Relationship has = user.createRelationshipTo(attribute, RelationshipTypes.HAS);
            has.setProperty(TIME, ZonedDateTime.now(utc));
            results = attribute.getAllProperties();
            results.put(HAVE, true);
            results.put(WANT, Wants.userWantsAttribute(user, attribute));
            results.put(HAS, attribute.getDegree(RelationshipTypes.HAS, Direction.INCOMING));
            results.put(WANTS, attribute.getDegree(RelationshipTypes.WANTS, Direction.INCOMING));
            tx.success();

Next is returning those relationships. We need a GET method with the username of the person who HAS the relationships, a limit and offset in case they have a lot and we want to paginate through them, and lastly the username of the person looking at this list. We want to be able to tell the person looking if they HAVE any of the attributes the first user WANTS and WANT any of the attributes the first user HAS.

 
    @GET
    public Response getHas(@PathParam("username") final String username,
                             @QueryParam("limit") @DefaultValue("25") final Integer limit,
                             @QueryParam("offset") @DefaultValue("0")  final Integer offset,
                             @QueryParam("username2") final String username2,
                             @Context GraphDatabaseService db) throws IOException {
        ArrayList<Map<String, Object>> results = new ArrayList<>();

        try (Transaction tx = db.beginTx()) {
            Node user = Users.findUser(username, db);
            Node user2;
            HashSet<Node> user2Has = new HashSet<>();
            HashSet<Node> user2Wants = new HashSet<>();
            if (username2 != null) {
                user2 = Users.findUser(username2, db);
                for (Relationship r1 : user2.getRelationships(Direction.OUTGOING, RelationshipTypes.HAS)) {
                    user2Has.add(r1.getEndNode());
                }
                for (Relationship r1 : user2.getRelationships(Direction.OUTGOING, RelationshipTypes.WANTS)) {
                    user2Wants.add(r1.getEndNode());
                }
            }

After we find the HAS and WANTS of the second user, we can check against the Attributes at the end of the HAS relationship for our first user. We want to once again get the degrees of the Attribute to see how popular it is. Lastly, we sort by date and return a subset based on our offset and limit.

 
           for (Relationship r1 : user.getRelationships(Direction.OUTGOING, RelationshipTypes.HAS)) {
                Node attribute = r1.getEndNode();
                Map<String, Object> properties = attribute.getAllProperties();
                ZonedDateTime time = (ZonedDateTime)r1.getProperty("time");
                properties.put(TIME, time);
                properties.put(HAVE, user2Has.contains(attribute));
                properties.put(WANT, user2Wants.contains(attribute));
                properties.put(WANTS, attribute.getDegree(RelationshipTypes.WANTS, Direction.INCOMING));
                properties.put(HAS, attribute.getDegree(RelationshipTypes.HAS, Direction.INCOMING));
                results.add(properties);
            }
            tx.success();
        }

        results.sort(sharedComparator.thenComparing(timedComparator));

        if (offset > results.size()) {
            return Response.ok().entity(objectMapper.writeValueAsString(
                    results.subList(0, 0)))
                    .build();
        } else {
            return Response.ok().entity(objectMapper.writeValueAsString(
                    results.subList(offset, Math.min(results.size(), limit + offset))))
                    .build();
        }

One thing when it comes to testing, since our custom ObjectMapper is returning dates in a specific format, we want to stick to that format when creating our test fixtures:

 
            "CREATE (fat:Attribute {name:'Fat'})" +
            "CREATE (bald:Attribute {name:'Bald'})" +
            "CREATE (rich:Attribute {name:'Rich'})" +
            "CREATE (jexp)-[:HAS {time: datetime('2018-07-19T17:12:56Z') }]->(fat)" +
            "CREATE (laeg)-[:WANTS {time: datetime('2018-07-19T17:38:57Z')}]->(bald)" +
            "CREATE (max)-[:HAS {time: datetime('2018-07-19T18:33:51Z') }]->(fat)" +

…and expected results.

 
    private static final ArrayList<HashMap<String, Object>> expected = new ArrayList<HashMap<String, Object>>() {{
        add(new HashMap<String, Object>() {{
            put("name", "Bald");
            put("time", "2018-07-19T19:41:23Z");
            put("has", 1);
            put("wants", 1);
            put("have", false);
            put("want", false);
        }});

I’ll spare you the code, but the WANTS relationship is just a mirror image of what we built just now. Let’s hook it up back to our application. First we need a model for Attribute:

 
@Data
public class Attribute {
    private Long id;
    private String name;
    private String lowercase_name;
    private String time;
    private Integer wants;
    private Integer has;
    private Boolean want;
    private Boolean have;

But we also want a little helper method to display the time in a simpler form. We parse the time as a String, and then convert it to what we want and how we want to display it.

 
    private static final DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("dd/MM/yyyy");

    public String when() {
        ZonedDateTime dateTime = ZonedDateTime.parse(time);
        return dateFormat.format(dateTime);
    }

Next, we will connect our backend to our API:

 
    @GET("users/{username}/has")
    Call<List<Attribute>> getHas(@Path("username") String username,
                                 @Query("limit") Integer limit,
                                 @Query("offset") Integer offset,
                                 @Query("username2") String username2);

…and use it in our Application. What follows is a little convoluted because we want to show some of the application to users that are not logged in. This will allow users who are considering joining the Dating site, but aren’t sure, take a peek and then decide if they want to register. We figure out who is asking for this data first, then we check to see if the user requested is valid, get their has relationships by the API and return them in a list.

 
      get("/user/{username}/has", req -> {
          String requested_by = req.get("requested_by");
          if (requested_by.equals("anonymous")) requested_by = null;
          User authenticated = getUserProfile(requested_by);

          Response<User> userResponse = api.getProfile(req.param("username").value(), requested_by).execute();
          if (userResponse.isSuccessful()) {
              User user = userResponse.body();
              Integer limit = req.param("limit").intValue(25);
              Integer offset = req.param("offset").intValue(0);

              Response<List<Attribute>> attributesResponse = api.getHas(user.getUsername(), limit, offset, requested_by).execute();
              List<Attribute> attributes = new ArrayList<>();
              if (attributesResponse.isSuccessful()) {
                  attributes = attributesResponse.body();
              }

              return views.attributes.template(authenticated, user, attributes);
          } else {
              throw new Err(Status.BAD_REQUEST);
          }
      });

If it all goes well, then our Application will look like this:

The WANTS relationships are once again a mirror image, so we’ll skip it. Turns out the LIKES and HATES relationships follow the same pattern but with a Thing instead of an Attribute. If you fast forward a little bit, our back-end API now looks like:

 
:POST   /v1/schema/create
:GET    /v1/users/{username}
:POST   /v1/users
:GET    /v1/users/{username}/has
:POST   /v1/users/{username}/has/{attribute}
:DELETE /v1/users/{username}/has/{attribute}
:GET    /v1/users/{username}/wants
:POST   /v1/users/{username}/wants/{attribute}
:DELETE /v1/users/{username}/wants/{attribute}
:GET    /v1/users/{username}/likes
:POST   /v1/users/{username}/likes/{thing}
:DELETE /v1/users/{username}/likes/{thing}
:GET    /v1/users/{username}/hates
:POST   /v1/users/{username}/hates/{thing}
:DELETE /v1/users/{username}/hates/{thing}

I don’t want you to be bored to death with every last detail so we’ll skip the DELETEs and the very similar methods and move on to other parts of the dating site in the next blog post. For those who want the details, please take a look at the source.

Tagged , , , , , , , ,

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: