Skip to content

Commit

Permalink
feat(thresholds): add threshold option
Browse files Browse the repository at this point in the history
This allows you to specify the threshold you want to filter items out.
By default it will filter out NO_MATCH
  • Loading branch information
Kent C. Dodds committed Aug 25, 2016
1 parent 5fd0c3f commit 1c3955b
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 29 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ matchSorter(list, 'h') // ['hi', 'hey', 'hello']
matchSorter(list, 'y') // ['yo', 'hey']
matchSorter(list, 'z') // []

// You can also pass an options object:
// **keys** (defaults to undefined and just uses the value itself as above)
const objList = [
{name: 'Janice', color: 'Green'},
{name: 'Fred', color: 'Orange'},
Expand All @@ -69,8 +71,31 @@ const objList = [
]
matchSorter(objList, 'g', {keys: ['name', 'color']}) // [{name: 'George', color: 'Blue'}, {name: 'Janice', color: 'Green'}]
matchSorter(objList, 're', {keys: ['color', 'name']}) // [{name: 'Jen', color: 'Red'}, {name: 'Janice', color: 'Green'}, {name: 'Fred', color: 'Orange'}]

// **threshold** (defaults to MATCH)
const fruit = ['orange', 'apple', 'grape', 'banana']
matchSorter(fruit, 'ap', {threshold: matchSorter.rankings.NO_MATCH}) // ['apple', 'grape', 'orange', 'banana'] (returns all items, just sorted by best match)
const things = ['google', 'airbnb', 'apple', 'apply', 'app'],
matchSorter(things, 'app', {threshold: matchSorter.rankings.EQUAL}) // ['app'] (only items that are equal)
const otherThings = ['fiji apple', 'google', 'app', 'crabapple', 'apple', 'apply']
matchSorter(otherThings, 'app', {threshold: matchSorter.rankings.WORD_STARTS_WITH}) // ['app', 'apple', 'apply', 'fiji apple'] (everything that matches with "word starts with" or better)

/*
* Available thresholds (from top to bottom) are:
* - EQUAL
* - STARTS_WITH
* - WORD_STARTS_WITH
* - CONTAINS
* - ACRONYM
* - MATCHES
* - NO_MATCH
*/
```

> In the examples above, we're using CommonJS. If you're using ES6 modules, then you can do:
>
> `import matchSorter, {rankings} from 'match-sorter'`
## Inspiration

Actually, most of this code was extracted from the _very first_ library I ever wrote: [genie][genie]!
Expand Down
3 changes: 1 addition & 2 deletions other/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
## Want to do

- Support nested `keys` like `name.first`
- Support a rank filter (return all items even those with no match, or only those with a certain ranking or higher)

## Might do

- Any ideas?
- Perf optimizations. Definitely some optimizations that could be made, but are they worth it? I'd like to get some benchmarks running!

## Wont do

Expand Down
47 changes: 24 additions & 23 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
const matchRankMap = {
equals: 5,
startsWith: 4,
wordStartsWith: 3,
contains: 2,
acronym: 1,
matches: 0,
noMatch: -1,
const rankings = {
EQUAL: 6,
STARTS_WITH: 5,
WORD_STARTS_WITH: 4,
CONTAINS: 3,
ACRONYM: 2,
MATCHES: 1,
NO_MATCH: 0,
}

export default matchSorter
matchSorter.rankings = rankings
export {matchSorter as default, rankings}

/**
* Takes an array of items and a value and returns a new array with the items that match the given value
Expand All @@ -18,13 +19,13 @@ export default matchSorter
* @return {Array} - the new sorted array
*/
function matchSorter(items, value, options = {}) {
const {keys} = options
const {keys, threshold = rankings.MATCHES} = options
const matchedItems = items.reduce(reduceItemsToRanked, [])
return matchedItems.sort(sortRankedItems).map(({item}) => item)

function reduceItemsToRanked(matches, item, index) {
const {rank, keyIndex} = getHighestRanking(item, keys, value)
if (rank > matchRankMap.noMatch) {
if (rank >= threshold) {
matches.push({item, rank, index, keyIndex})
}
return matches
Expand All @@ -42,11 +43,11 @@ function getHighestRanking(item, keys, value) {
keyIndex = i
}
return {rank, keyIndex}
}, {rank: matchRankMap.noMatch, keyIndex: -1})
}, {rank: rankings.NO_MATCH, keyIndex: -1})
}

/**
* Gives a matchRankMap score based on how well the two strings match.
* Gives a rankings score based on how well the two strings match.
* @param {String} testString - the string to test against
* @param {String} stringToRank - the string to rank
* @returns {Number} the ranking for how well stringToRank matches testString
Expand All @@ -58,37 +59,37 @@ function getMatchRanking(testString, stringToRank) {

// too long
if (stringToRank.length > testString.length) {
return matchRankMap.noMatch
return rankings.NO_MATCH
}

// equals
if (testString === stringToRank) {
return matchRankMap.equals
return rankings.EQUAL
}

// starts with
if (testString.indexOf(stringToRank) === 0) {
return matchRankMap.startsWith
return rankings.STARTS_WITH
}

// word starts with
if (testString.indexOf(` ${stringToRank}`) !== -1) {
return matchRankMap.wordStartsWith
return rankings.WORD_STARTS_WITH
}

// contains
if (testString.indexOf(stringToRank) !== -1) {
return matchRankMap.contains
return rankings.CONTAINS
} else if (stringToRank.length === 1) {
// If the only character in the given stringToRank
// isn't even contained in the testString, then
// it's definitely not a match.
return matchRankMap.noMatch
return rankings.NO_MATCH
}

// acronym
if (getAcronym(testString).indexOf(stringToRank) !== -1) {
return matchRankMap.acronym
return rankings.ACRONYM
}

return stringsByCharOrder(testString, stringToRank)
Expand All @@ -113,7 +114,7 @@ function getAcronym(string) {
}

/**
* Returns a matchRankMap.matches or noMatch score based on whether
* Returns a rankings.matches or noMatch score based on whether
* the characters in the stringToRank are found in order in the
* testString
* @param {String} testString - the string to test against
Expand All @@ -140,10 +141,10 @@ function stringsByCharOrder(testString, stringToRank) {
const matchChar = stringToRank[i]
const found = findMatchingCharacter(matchChar, testString)
if (!found) {
return matchRankMap.noMatch
return rankings.NO_MATCH
}
}
return matchRankMap.matches
return rankings.MATCHES
}

/**
Expand Down
47 changes: 43 additions & 4 deletions src/index.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint ava/no-only-test:0, ava/no-skip-test:0 */
import test from 'ava'
import matchSorter from './'
import matchSorter, {rankings} from './'

const tests = {
'returns an empty array with a string that is too long': {
Expand Down Expand Up @@ -114,11 +115,49 @@ const tests = {
{first: 'not', second: 'not', third: 'not', fourth: 'match'},
],
},
'when providing a rank threshold of NO_MATCH, it returns all of the items': {
input: [
['orange', 'apple', 'grape', 'banana'],
'ap',
{threshold: rankings.NO_MATCH},
],
output: [
'apple', 'grape', 'orange', 'banana',
],
},
'when providing a rank threshold of EQUAL, it returns only the items that are equal': {
input: [
['google', 'airbnb', 'apple', 'apply', 'app'],
'app',
{threshold: rankings.EQUAL},
],
output: [
'app',
],
},
'when providing a rank threshold of WORD_STARTS_WITH, it returns only the items that are equal': {
input: [
['fiji apple', 'google', 'app', 'crabapple', 'apple', 'apply'],
'app',
{threshold: rankings.WORD_STARTS_WITH},
],
output: [
'app', 'apple', 'apply', 'fiji apple',
],
},
}

Object.keys(tests).forEach(title => {
test(title, t => {
const {input, output} = tests[title]
const {input, output, only, skip} = tests[title]
if (only) {
test.only(title, testFn)
} else if (skip) {
test.skip(title, testFn)
} else {
test(title, testFn)
}

function testFn(t) {
t.deepEqual(output, matchSorter(...input))
})
}
})

0 comments on commit 1c3955b

Please sign in to comment.