My dating app has this function where I rank user based on an algorithm which takes their last login date, and some other ‘factors’.
I thought this was gonna be easy. Well, it would’ve been if I wanted to take the entire collection to the client, the use JS to calculate the field, sort, and display to user.
Problem is, I can’t load the entire collection. That’s thousands of users. I have to sort BEFORE I return the result. Which means I have to sort at the database level before I return the results, which was quite challenging.
How-to:
First the context is, I have a ‘paginated’ with a load-more button page, which uses template-level subscription to reactively subscribe and retrieve data incrementally (for fast loading!!):
Template.SearchMain.onCreated(function() { var self = this; // initialize or reset the limit variables Session.set('limit', 9); self.autorun(function() { // make sure user is initialized already var user = Meteor.user; if (user) { // set query based on profile.ideal, and sorting based on last login and online status // return pipeline object ready to be ran through aggregate var pipeline = SearchesHelper.searchUsersPipeline(); // make sure it did return before proceeding if (pipeline) { pipeline.push({ $limit: Session.get('limit') }); // make subscription based on options self.subscriptionHandle = self.subscribe('searchUsers', pipeline); } Tracker.afterFlush(function() { $('#loadingUsers').waitMe({ text: '載入中...', bg: 'transparent'}); }); } }); });
The pipeline is where it got challenging:
SearchesHelper.searchUsersPipeline = function() { var user = Meteor.user(); if (user) { var yearNow = new Date().getFullYear(); var earliestDob = new Date(new Date().setFullYear(yearNow - user.profile.ideal.ageTo - 1)); var latestDob = new Date(new Date().setFullYear(yearNow - user.profile.ideal.ageFrom)); var query = { // opposite gender of current user only 'profile.gender': user.profile.gender == 'male' ? 'female' : 'male', // active members only userStatus: 'active', // filter by profile.ideal.ageFrom and ageTo 'profile.dob': {$gte: earliestDob, $lte: latestDob} }; // filter out users already passed on, or got passed on, or matches that were cancelled var userId = user._id; var skipUserIds = []; var userIsSenderOrReceiverQuery = { $or: [{receiverId: userId}, {senderId: userId}] }; var matchIsPassedOrCancelledQuery = { $or: [{passed: true}, {cancelled: true}] }; var skipMatchesCursor = Matches.find({ $and: [userIsSenderOrReceiverQuery, matchIsPassedOrCancelledQuery] }); // pass the sender if user is receiver, vice versa skipMatchesCursor.forEach(function(match) { userId == match.receiverId ? skipUserIds.push(match.senderId) : skipUserIds.push(match.receiverId); }); // exclude user self as well skipUserIds.push(userId); // skip all users in skipUserIds array query['_id'] = {$nin: skipUserIds}; // filter city if (user.profile.ideal.city != 'city_nopref') query['profile.city'] = user.profile.ideal.city; // filter education level if (user.profile.ideal.education != 'education_nopref') { var educationLevels = ['primary', 'secondary', 'associate', 'bachelor', 'masters', 'doctors']; var idealLevel = educationLevels.indexOf(user.profile.ideal.education); var acceptEduArray = educationLevels.slice(idealLevel, educationLevels.length); query['profile.education'] = { $in: acceptEduArray }; } // filter children if (user.profile.ideal.children != 'children_nopref') query['profile.children'] = 'none'; // filter smoke var smokingLevels = ['never_smoke', 'quit_smoke', 'seldom_smoke', 'sometime_smoke', 'always_smoke']; if (user.profile.ideal.smoke == 'sometime_smoke') { query['profile.smoke'] = { $ne: smokingLevels[4] }; } else if (user.profile.ideal.smoke == 'no_smoke') { query['profile.smoke'] = { $in: [smokingLevels[0], smokingLevels[1]] } } // filter drink var drinkingLevels = ['never_drink', 'quit_drink', 'seldom_drink', 'sometime_drink', 'always_drink']; if (user.profile.ideal.drink == 'sometime_drink') { query['profile.drink'] = { $ne: drinkingLevels[4] }; } else if (user.profile.ideal.drink == 'no_drink') { query['profile.drink'] = { $in: [drinkingLevels[0], drinkingLevels[1]] } } // set how many days would substract one point var msPerScore = 86400000 * 2; // Diff between now and lastLogin, in MS var dateDiffOperator = { $subtract: [ new Date(), "$status.lastLogin.date" ] }; // return dateScore from lastLogin date var dateScoreOperator = { $subtract: [10, { $divide: [dateDiffOperator, msPerScore] }] }; // return totalScore from weighted geometric average of adminScore and DateScore var totalScoreOperator = { $multiply: [dateScoreOperator, "$adminScore"]}; var project = { username: 1, profile: 1, status: 1, userStatus: 1, todayMatch: 1, starEnd: 1, adminScore: 1, dateScore: dateScoreOperator, totalScore: totalScoreOperator }; var sort = { 'totalScore': -1 }; return [ { $match: query }, { $project: project }, { $sort: sort } ] } };
Basically the logic is this.
First the query, I filter out all the search results based on user’s preference (if they want a partner that is certain age range, doesn’t smoke, etc).
Then I have to make a projection. This is the most challenging part. I have to dig through mongoDB’s API to find out their arithmetic APIs. Also apparently some didn’t work on my version of meteor mongo, so things like floor, square, which I wanted to use, was not available. Perhaps that’s available by now. Anyways I decided to get by with basic multiplications and subtractions.
Once I calculated the ‘dateScore’ and ‘totalScore’ field, then the easiest part and the end, sort by totalScore in descending order.
Packages:
meteorhacks:aggregate (it’s needed to use the mongo aggregate function within Meteor. Not sure if that’s still needed in the future, maybe meteor will integrate that into the meteor mongo)
Challenges:
This task was really stretching my mongoDB abilities. I had taken the MongoDB University 12-week course, which went through all of this, but putting it in practice and learning is completely different.
Nevertheless I was glad that I knew about the mongoDB aggregation pipeline, and know how to dig deeper to find the stuff I needed to complete this task.