Skip to content

Commit 9ab83e7

Browse files
committed
Implemented levenshtein distance algorithm and changed unit tests to match
1 parent 9a69e7b commit 9ab83e7

File tree

2 files changed

+44
-74
lines changed

2 files changed

+44
-74
lines changed

api/main_endpoints/routes/ShortcutSearch.js

Lines changed: 36 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -27,78 +27,48 @@ router.post('/', async function(req, res) {
2727
return res.status(OK).send({ items: [] });
2828
}
2929

30-
const query = req.body.query.replace(/[*\s]/g, '');
30+
// function for calculating how similar strings are to each other via levenshtein distance
31+
function levenshteinDistance(a, b) {
32+
const m = a.length;
33+
const n = b.length;
3134

32-
// Create a fuzzy regex pattern to match characters in order, e.g., "pone" -> /p.*o.*n.*e/i
33-
const fuzzyPattern = query.split('').join('.*');
34-
const pattern = new RegExp(fuzzyPattern, 'i');
35+
if (m === 0) return n;
36+
if (n === 0) return m;
3537

36-
const maybeOr = {
37-
$or: [
38-
{
39-
$expr: {
40-
$regexMatch: {
41-
input: { $concat: ['$firstName', '$lastName'] },
42-
regex: pattern,
43-
}
44-
}
45-
},
46-
{ email: { $regex: new RegExp(query, 'i')} }
47-
]
48-
};
38+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1));
39+
for (let i = 0; i <= m; i++) dp[i][0] = i;
40+
for (let j = 0; j <= n; j++) dp[0][j] = j;
4941

50-
/**
51-
* Function to calculate scores based on token matches for sorting
52-
* @param {string} str - The string to score against
53-
* @param {Array} tokens - The tokens to match against the string
54-
* @return {number} - The score based on matches
55-
*/
56-
const tokenScores = (str, tokens) => {
57-
return tokens.reduce((score, token) => {
58-
if (str.startsWith(token)) return score; // highest score for exact match
59-
if (str.includes(token)) return score + 1; // lower score for partial match
60-
return score + 2; // lowest score for no match
61-
}, 0);
62-
};
63-
64-
/**
65-
* Sorts the user items based on the query match
66-
* @param {string} query input string to match against
67-
* @returns {function} - A comparison function for sorting
68-
*/
69-
const sortByMatch = (query) => {
70-
const input = query.toLowerCase().split(/[\s@._-]+/).filter(Boolean);
71-
72-
return (a, b) => {
73-
const aName = (a.firstName + ' ' + a.lastName).toLowerCase();
74-
const bName = (b.firstName + ' ' + b.lastName).toLowerCase();
75-
const aEmail = a.email.toLowerCase();
76-
const bEmail = b.email.toLowerCase();
77-
78-
// First Priority: sort by name match
79-
const nameScoreA = tokenScores(aName, input);
80-
const nameScoreB = tokenScores(bName, input);
81-
if (nameScoreA !== nameScoreB) {
82-
return nameScoreA - nameScoreB;
83-
}
84-
85-
// Second Priority: sort by email match
86-
const emailScoreA = tokenScores(aEmail, input);
87-
const emailScoreB = tokenScores(bEmail, input);
88-
if (emailScoreA !== emailScoreB) {
89-
return emailScoreA - emailScoreB;
42+
for (let i = 1; i <= m; i++) {
43+
for (let j = 1; j <= n; j++) {
44+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
45+
dp[i][j] = Math.min(
46+
dp[i - 1][j] + 1,
47+
dp[i][j - 1] + 1,
48+
dp[i - 1][j - 1] + cost
49+
);
9050
}
51+
}
52+
return dp[m][n];
53+
}
9154

92-
// Tie-breaker: alphabetical email sort
93-
return a.email.localeCompare(b.email);
94-
};
95-
};
55+
// Find top 5 matching users and sort results based on best match of name or email
56+
User.find({}, { password: 0 })
57+
.then(users => {
58+
const matchingUsers = users.map(user => {
59+
const firstNameScore = levenshteinDistance(req.body.query, user.firstName);
60+
const lastNameScore = levenshteinDistance(req.body.query, user.lastName);
61+
const emailScore = levenshteinDistance(req.body.query, user.email);
62+
return {
63+
user,
64+
score: Math.min(firstNameScore, lastNameScore, emailScore)
65+
};
66+
});
9667

97-
// Find user and sort results based on best match of full name or email
98-
User.find(maybeOr, { password: 0 })
99-
.limit(5)
100-
.then(items => {
101-
items.sort(sortByMatch(req.body.query));
68+
const items = matchingUsers
69+
.sort((a, b) => a.score - b.score)
70+
.slice(0, 5)
71+
.map(item => item.user);
10272
res.status(OK).send({ items });
10373
})
10474
.catch((error) => {

test/api/ShortcutSearch.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,31 +205,31 @@ describe('ShortcutSearch', () => {
205205
expect(result.body.items).that.is.an('array').to.have.lengthOf(5);
206206
});
207207

208-
it('Should return no records when query = \'Pika\'', async () => {
208+
it('Should return FIVE records when query = \'Pika\'', async () => {
209209
const result = await test.sendPostRequestWithToken(token, url, { query: 'Pika' });
210210
expect(result).to.have.status(OK);
211-
expect(result.body.items).that.is.an('array').that.is.empty;
211+
expect(result.body.items).that.is.an('array').to.have.lengthOf(5);
212212
});
213213

214214
beforeEach(() => {
215215
setTokenStatus(true, { accessLevel: MEMBERSHIP_STATE.ADMIN });
216216
});
217217

218-
it('Should return THREE records when query = \'coOl\'', async () => {
218+
it('Should return FIVE records when query = \'coOl\'', async () => {
219219
const result = await test.sendPostRequestWithToken(token, url, queryUser);
220220
expect(result).to.have.status(OK);
221-
expect(result.body.items).that.is.an('array').to.have.lengthOf(3);
221+
expect(result.body.items).that.is.an('array').to.have.lengthOf(5);
222222
});
223223

224224
it('Should show results sorted by best match of name and email', async () => {
225225
const result = await test.sendPostRequestWithToken(token, url, fiveMatchUsers);
226226
expect(result).to.have.status(OK);
227227
expect(result.body.items.map(u => u.email)).to.eql([
228228
229-
'test0@test.com',
230-
'test00@test.com',
231-
'test2@test.com',
232-
'test3@test.com'
229+
'test3@test.com',
230+
'test5@test.com',
231+
'test7@test.com',
232+
'test0@test.com'
233233
]);
234234
});
235235
});

0 commit comments

Comments
 (0)