Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: handle nested arrays with wildcard keys #120

16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,25 @@ const nestedObjList = [
]
matchSorter(nestedObjList, 'j', {keys: ['name.0.first']})
// [{name: {first: 'Janice'}}, {name: {first: 'Jen'}}]

// matchSorter(nestedObjList, 'j', {keys: ['name[0].first']}) does not work
```

This even works with arrays of multiple nested objects: just specify the key
using dot-notation with the `*` wildcard instead of a numeric index.

```javascript
const nestedObjList = [
{aliases: [{name: {first: 'Janice'}},{name: {first: 'Jen'}}]},
{aliases: [{name: {first: 'Fred'}},{name: {first: 'Frederic'}}]},
{aliases: [{name: {first: 'George'}},{name: {first: 'Georgie'}}]},
]
matchSorter(nestedObjList, 'jen', {keys: ['aliases.*.name.first']})
// [{aliases: [{name: {first: 'Janice'}},{name: {first: 'Jen'}}]}]
matchSorter(nestedObjList, 'jen', {keys: ['aliases.0.name.first']})
// []
```

**Property Callbacks**: Alternatively, you may also pass in a callback function
that resolves the value of the key(s) you wish to match on. This is especially
useful when interfacing with libraries such as Immutable.js
Expand Down
107 changes: 106 additions & 1 deletion src/__tests__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const tests: Record<string, TestCase> = {
],
output: [{name: 'A', age: 0}],
},
'can handle objected with nested keys': {
'can handle object with nested keys': {
input: [
[
{name: {first: 'baz'}},
Expand All @@ -129,6 +129,36 @@ const tests: Record<string, TestCase> = {
],
output: [{name: {first: 'bat'}}, {name: {first: 'baz'}}],
},
'can handle object with an array of values with nested keys with a specific index': {
input: [
[
{aliases: [{name: {first: 'baz'}},{name: {first: 'foo'}},{name: null}]},
{aliases: [{name: {first: 'foo'}},{name: {first: 'bat'}},null]},
{aliases: [{name: {first: 'foo'}},{name: {first: 'foo'}}]},
{aliases: null},
{},
null,
],
'ba',
{keys: ['aliases.0.name.first']},
],
output: [{aliases: [{name: {first: 'baz'}},{name: {first: 'foo'}},{name: null}]}],
},
'can handle object with an array of values with nested keys with a wildcard': {
input: [
[
{aliases: [{name: {first: 'baz'}},{name: {first: 'foo'}},{name: null}]},
{aliases: [{name: {first: 'foo'}},{name: {first: 'bat'}},null]},
{aliases: [{name: {first: 'foo'}},{name: {first: 'foo'}}]},
{aliases: null},
{},
null,
],
'ba',
{keys: ['aliases.*.name.first']},
],
output: [{aliases: [{name: {first: 'baz'}},{name: {first: 'foo'}},{name: null}]}, {aliases: [{name: {first: 'foo'}},{name: {first: 'bat'}},null]}],
},
'can handle property callback': {
input: [
[{name: {first: 'baz'}}, {name: {first: 'bat'}}, {name: {first: 'foo'}}],
Expand All @@ -153,6 +183,81 @@ const tests: Record<string, TestCase> = {
{favoriteIceCream: ['mint', 'chocolate']},
],
},
'can handle keys that are an array of values with a wildcard': {
input: [
[
{favoriteIceCream: ['mint', 'chocolate']},
{favoriteIceCream: ['candy cane', 'brownie']},
{favoriteIceCream: ['birthday cake', 'rocky road', 'strawberry']},
],
'cc',
{keys: ['favoriteIceCream.*']},
],
output: [
{favoriteIceCream: ['candy cane', 'brownie']},
{favoriteIceCream: ['mint', 'chocolate']},
],
},
'can handle nested keys that are an array of values': {
input: [
[
{favorite: {iceCream: ['mint', 'chocolate']}},
{favorite: {iceCream: ['candy cane', 'brownie']}},
{favorite: {iceCream: ['birthday cake', 'rocky road', 'strawberry']}},
],
'cc',
{keys: ['favorite.iceCream']},
],
output: [
{favorite: {iceCream: ['candy cane', 'brownie']}},
{favorite: {iceCream: ['mint', 'chocolate']}},
],
},
'can handle nested keys that are an array of values with a wildcard': {
input: [
[
{favorite: {iceCream: ['mint', 'chocolate']}},
{favorite: {iceCream: ['candy cane', 'brownie']}},
{favorite: {iceCream: ['birthday cake', 'rocky road', 'strawberry']}},
],
'cc',
{keys: ['favorite.iceCream.*']},
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with this working, but I'd also like to support favorite.iceCream here as well. We should be able to process an item of String | Array<String> and favorite.icCream is Array<String>.

],
output: [
{favorite: {iceCream: ['candy cane', 'brownie']}},
{favorite: {iceCream: ['mint', 'chocolate']}},
],
},
'can handle nested keys that are an array of objects with a single wildcard': {
input: [
[
{favorite: {iceCream: [{tastes: ['vanilla', 'mint']}, {tastes: ['vanilla', 'chocolate']}]}},
{favorite: {iceCream: [{tastes: ['vanilla', 'candy cane']}, {tastes: ['vanilla', 'brownie']}]}},
{favorite: {iceCream: [{tastes: ['vanilla', 'birthday cake']}, {tastes: ['vanilla', 'rocky road']}, {tastes: ['strawberry']}]}},
],
'cc',
{keys: ['favorite.iceCream.*.tastes']},
],
output: [
{favorite: {iceCream: [{tastes:['vanilla', 'candy cane']}, {tastes:['vanilla', 'brownie']}]}},
{favorite: {iceCream: [{tastes:['vanilla', 'mint']}, {tastes:['vanilla', 'chocolate']}]}},
],
},
'can handle nested keys that are an array of objects with two wildcards': {
input: [
[
{favorite: {iceCream: [{tastes: ['vanilla', 'mint']}, {tastes: ['vanilla', 'chocolate']}]}},
{favorite: {iceCream: [{tastes: ['vanilla', 'candy cane']}, {tastes: ['vanilla', 'brownie']}]}},
{favorite: {iceCream: [{tastes: ['vanilla', 'birthday cake']}, {tastes: ['vanilla', 'rocky road']}, {tastes: ['strawberry']}]}},
],
'cc',
{keys: ['favorite.iceCream.*.tastes.*']},
],
output: [
{favorite: {iceCream: [{tastes:['vanilla', 'candy cane']}, {tastes:['vanilla', 'brownie']}]}},
{favorite: {iceCream: [{tastes:['vanilla', 'mint']}, {tastes:['vanilla', 'chocolate']}]}},
],
},
'can handle keys with a maxRanking': {
input: [
[
Expand Down
109 changes: 73 additions & 36 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,13 @@ type KeyOption<ItemType> =
| ValueGetterKey<ItemType>
| string

// ItemType = unknown allowed me to make these changes without the need to change the current tests
interface MatchSorterOptions<ItemType = unknown> {
keys?: Array<KeyOption<ItemType>>
threshold?: number
baseSort?: BaseSorter<ItemType>
keepDiacritics?: boolean
}
type IndexableByString = Record<string, unknown>

const rankings = {
CASE_SENSITIVE_EQUAL: 7,
Expand Down Expand Up @@ -260,7 +260,7 @@ function getClosenessRanking(testString: string, stringToRank: string): number {
string: string,
index: number,
) {
for (let j = index; j < string.length; j++) {
for (let j = index, J = string.length; j < J; j++) {
const stringChar = string[j]
if (stringChar === matchChar) {
matchingInOrderCharCount += 1
Expand All @@ -280,7 +280,7 @@ function getClosenessRanking(testString: string, stringToRank: string): number {
return rankings.NO_MATCH
}
charNumber = firstIndex
for (let i = 1; i < stringToRank.length; i++) {
for (let i = 1, I = stringToRank.length; i < I; i++) {
const matchChar = stringToRank[i]
charNumber = findMatchingCharacter(matchChar, testString, charNumber)
const found = charNumber > -1
Expand Down Expand Up @@ -349,42 +349,76 @@ function prepareValueForComparison<ItemType>(
function getItemValues<ItemType>(
item: ItemType,
key: KeyOption<ItemType>,
): Array<string> | null {
): Array<string> {
if (typeof key === 'object') {
key = key.key as string
}
let value: string | Array<string> | null
let value: string | Array<string> | null | unknown
if (typeof key === 'function') {
value = key(item)
// eslint-disable-next-line no-negated-condition
} else if (item == null) {
value = null
} else if (Object.hasOwnProperty.call(item, key)) {
value = (item as IndexableByString)[key]
} else if (key.includes('.')) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
return getNestedValues<ItemType>(key, item)
} else {
value = getNestedValue<ItemType>(key, item)
value = null
}
const values: Array<string> = []
// concat because `value` can be a string or an array
// eslint-disable-next-line
return value != null ? values.concat(value) : null

// because `value` can also be undefined
if (value == null) {
return []
}
if (Array.isArray(value)) {
return value
}
return [String(value)]
}

/**
* Given key: "foo.bar.baz"
* And obj: {foo: {bar: {baz: 'buzz'}}}
* Given path: "foo.bar.baz"
* And item: {foo: {bar: {baz: 'buzz'}}}
* -> 'buzz'
* @param key a dot-separated set of keys
* @param obj the object to get the value from
* @param path a dot-separated set of keys
* @param item the item to get the value from
*/
function getNestedValue<ItemType>(
key: string,
obj: ItemType,
): string | Array<string> | null {
// @ts-expect-error really have no idea how to type this properly...
return key.split('.').reduce((itemObj: object | null, nestedKey: string):
| object
| string
| null => {
// @ts-expect-error lost on this one as well...
return itemObj ? itemObj[nestedKey] : null
}, obj)
function getNestedValues<ItemType>(
path: string,
item: ItemType,
): Array<string> {
type ValueA = Array<ItemType | IndexableByString | string>
let values: ValueA = [item]
for (const nestedKey of path.split('.')) {
let nestedValues: ValueA = []

for (const nestedItem of values) {
if (nestedItem == null) continue

if (Object.hasOwnProperty.call(nestedItem, nestedKey)) {
const nestedValue = (nestedItem as IndexableByString)[nestedKey]
if (nestedValue != null) {
nestedValues.push(nestedValue as IndexableByString | string)
}
} else if (nestedKey === '*') {
// ensure that values is an array
nestedValues = nestedValues.concat(nestedItem)
}
}

values = nestedValues
}

if (Array.isArray(values[0])) {
// keep allowing the implicit wildcard for an array of strings at the end of
// the path; don't use `.flat()` because that's not available in node.js v10
const result: Array<string> = []
return result.concat(...(values as Array<string>))
}
// Based on our logic it should be an array of strings by now...
// assuming the user's path terminated in strings
return values as Array<string>
}

/**
Expand All @@ -398,17 +432,15 @@ function getAllValuesToRank<ItemType>(
keys: Array<KeyOption<ItemType>>,
) {
return keys.reduce<Array<{itemValue: string; attributes: KeyAttributes}>>(
(allVals, key) => {
const values = getItemValues(item, key)
if (values) {
values.forEach(itemValue => {
allVals.push({
itemValue,
attributes: getKeyAttributes(key),
})
(allValues, key) => {
const attributes = getKeyAttributes(key)
for (const itemValue of getItemValues(item, key)) {
allValues.push({
itemValue,
attributes,
})
}
return allVals
return allValues
},
[],
)
Expand Down Expand Up @@ -440,3 +472,8 @@ export type {
RankingInfo,
ValueGetterKey,
}

/*
eslint
no-continue: "off",
*/