Skip to content

Commit

Permalink
feat: handle nested arrays with wildcard keys (#120)
Browse files Browse the repository at this point in the history
* feat: handle nested arrays with wildcard keys

* perf: only get the key attributes once

* perf: handle trivial cases directly

* perf: cache the length of the string that's iterated over

* fix: type errors and warnings

* fix: recurse instead of duplicate, forgoing implicit wildcards

* fix: implicitly handle nested keys that are an array of values again

* refactor: remove reduces

* refactor: remove ts-expect-errors

* refactor: forgo for-of loops in favor of performance

Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
  • Loading branch information
mart-jansink and kentcdodds authored Dec 31, 2020
1 parent 4c2e927 commit b3d9d8d
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 43 deletions.
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.*']},
],
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
127 changes: 85 additions & 42 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,82 @@ 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> {
const keys = path.split('.')

type ValueA = Array<ItemType | IndexableByString | string>
let values: ValueA = [item]

for (let i = 0, I = keys.length; i < I; i++) {
const nestedKey = keys[i]
let nestedValues: ValueA = []

for (let j = 0, J = values.length; j < J; j++) {
const nestedItem = values[j]

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 @@ -397,21 +437,19 @@ function getAllValuesToRank<ItemType>(
item: 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),
})
})
}
return allVals
},
[],
)
const allValues: Array<{itemValue: string, attributes: KeyAttributes}> = []
for (let j = 0, J = keys.length; j < J; j++) {
const key = keys[j]
const attributes = getKeyAttributes(key)
const itemValues = getItemValues(item, key)
for (let i = 0, I = itemValues.length; i < I; i++) {
allValues.push({
itemValue: itemValues[i],
attributes,
})
}
}
return allValues
}

const defaultKeyAttributes = {
Expand Down Expand Up @@ -440,3 +478,8 @@ export type {
RankingInfo,
ValueGetterKey,
}

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

0 comments on commit b3d9d8d

Please sign in to comment.