agm1984
agm1984

Reputation: 17132

How to filter an array of objects for case insensitive matches from any object key

I have this sample code here, and I am trying to filter matching objects without exploding the code complexity or performance:

This code here filters matches based on one explicitly defined key and it's not case insensitive.

const people = [
  { firstName: 'Bob', lastName: 'Smith', status: 'single' },
  { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
  { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' },
  { firstName: 'Sally', lastName: 'Fields', status: 'relationship' },
  { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
  { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' },
  { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship'
    rogueBonusKey: 'bob likes salmon' },
]

const searchString = 'Bob'

const found = people.filter((person) => {
  if (person.firstName === searchString) return true
})

console.log(found)

THE GOAL:

  1. I want it to match case-insensitive
  2. I want it to return matches from any key
  3. I want it to find using contains not exact match

Something like this:

// const people = [
//   { firstName: 'Bob', lastName: 'Smith', status: 'single' },
//   { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
//   { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' },
//   { firstName: 'Sally', lastName: 'Fields', status: 'relationship' },
//   { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
//   { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' },
//   { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship'
//     rogueBonusKey: 'bob likes salmon' },
// ]

// const searchString = 'bob'

// ... magic

// console.log(found)

// { firstName: 'Bob', lastName: 'Smith', status: 'single' },
// { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
// { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
// { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship'
//   rogueBonusKey: 'bob likes salmon' },

I have scoured the documentations related to Array.filter() and I can definitely make solutions that involve Array.reduce() and looping over stuff with Object.keys(obj).forEach(), but I want to know if there is a concise, performant way to handle this kind of fuzzy search.

Something like this:

const people = [
  { firstName: 'Bob', lastName: 'Smith', status: 'single' },
  { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
  { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' },
  { firstName: 'Sally', lastName: 'Fields', status: 'relationship' },
  { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
  { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' },
  { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship' },
    rogueBonusKey: 'bob likes salmon' },
]

const searchString = 'Bob'

const found = people.filter((person) => {
  if (person.toString().indexOf(searchString).toLowerCase !== -1) return true
})

console.log(found)

[edit] This definitely works, but is it acceptable?

const people = [
  { firstName: 'Bob', lastName: 'Smith', status: 'single' },
  { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
  { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' },
  { firstName: 'Sally', lastName: 'Fields', status: 'relationship' },
  { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
  { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' },
  { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship',
    rogueBonusKey: 'bob likes salmon' },
]

const searchString = 'Bob'

const found = people.filter((person) => {
  const savageMatch = JSON.stringify(person)
    .toLowerCase()
    .indexOf(searchString.toLowerCase()) !== -1

  console.log(savageMatch)
  if (savageMatch) return true
})

console.log(found)

Memory footprint optimized:

const found = people.filter((person) => JSON.stringify(person)
    .toLowerCase()
    .indexOf(searchString.toLowerCase()) !== -1
)

Converted to a function:

const fuzzyMatch = (collection, searchTerm) =>
  collection.filter((obj) => JSON.stringify(obj)
    .toLowerCase()
    .indexOf(searchTerm.toLowerCase()) !== -1
)

console.log(fuzzyMatch(people, 'bob'))

There are some great answers in here; so far, I have selected this for my filtering needs:

const people = [
  { imageURL: 'http://www.alice.com/goat.jpeg', firstName: 'Bob', lastName: 'Smith', status: 'single' },
  { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
  { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' },
  { firstName: 'Sally', lastName: 'Fields', status: 'relationship' },
  { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
  { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' },
  {
    firstName: 'Ronald', lastName: 'McDonlad', status: 'relationship',
    rogueBonusKey: 'bob likes salmon'
  },
  {
    imageURL: 'http://www.bob.com/cats.jpeg', firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship',
    rogueBonusKey: 'bob hates salmon'
  },
]

const searchString = 'bob'

const options = {
  caseSensitive: false,
  excludedKeys: ['imageURL', 'firstName'],
}

const customFind = (collection, term, opts) => {
  const filterBy = () => {
    const searchTerms = (!opts.caseSensitive) ? new RegExp(term, 'i') : new RegExp(term)
    return (obj) => {
      for (const key of Object.keys(obj)) {
        if (searchTerms.test(obj[key]) &&
          !opts.excludedKeys.includes(key)) return true
      }
      return false
    }
  }
  return collection.filter(filterBy(term))
}

const found = customFind(people, searchString, options)

console.log(found)

I made it able to support case sensitivity and to exclude specific keys.

Upvotes: 12

Views: 15908

Answers (7)

codedbychavez
codedbychavez

Reputation: 311

Array.prototype.filter() also works as well:

const includesQuery = (value) => {
      return value['SOME_KEY'].toLowerCase().includes(query.toLowerCase());
    }
const filtered = this.myArray.filter(includesQuery);

console.log(filtered);

Upvotes: 0

Yosvel Quintero
Yosvel Quintero

Reputation: 19070

You can also use Regular expressions with i modifier to perform case-insensitive matching and the method RegExp.prototype.test()

  • It is very convenient when you want to evaluate multiple object properties like:

    new RegExp(searchString, 'i').test( 
      person.email || person.firstName || person.lastName
    )
    

Code:

const people = [{ firstName: 'Bob', lastName: 'Smith', status: 'single' }, { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' }, { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' }, { firstName: 'Sally', lastName: 'Fields', status: 'relationship' }, { firstName: 'Robert', lastName: 'Bobler', status: 'single' }, { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' }, { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship', rogueBonusKey: 'bob likes salmon' }]

const searchString = 'Bob'

const found = people.filter(({ firstName }) => 
  new RegExp(searchString, 'i').test(firstName))

console.log(found)

Upvotes: 2

xinthose
xinthose

Reputation: 3820

You can use Array.prototype.find().

const people = [
  { firstName: 'Bob', lastName: 'Smith', status: 'single' },
  { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
  { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' },
  { firstName: 'Sally', lastName: 'Fields', status: 'relationship' },
  { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
  { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' },
  { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship',
    'rogueBonusKey': 'bob likes salmon' },
]

const searchString = 'Bob';

const person = people.find(({ firstName }) => firstName.toLowerCase() === searchString.toLowerCase());

console.log(person);

Upvotes: 1

synthet1c
synthet1c

Reputation: 6282

you need to filter the array, then filter each key in the objects to match a regular expression. This example breaks down the problem into single responsibility funcitons and connects them with functional concepts eg.

Performance tests are included, in chrome this is consistently faster than Dmitry's example. I have not tested any other browsers. This may be because of optimisations that chrome takes to allow the jit to process the script faster when the code is expressed as small single responsibility functions that only take one type of data as an input and one type of data as an output.

Due to the tests this takes around 4 seconds to load.

const people = [
  { firstName: 'Bob', lastName: 'Smith', status: 'single' },
  { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
  { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' },
  { firstName: 'Sally', lastName: 'Fields', status: 'relationship' },
  { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
  { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' },
  { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship', rogueBonusKey: 'bob likes salmon' },
]

// run a predicate function over each key of an object
// const hasValue = f => o => 
//  Object.keys(o).some(x => f(o[x]))
const hasValue = f => o => {
  let key
  for (key in o) {
    if (f(o[key])) return true
  }
  return false
}

// convert string to regular expression
const toReg = str => 
  new RegExp(str.replace(/\//g, '//'), 'gi')

// test a string with a regular expression
const match = reg => x => 
  reg.test(x)

// filter an array by a predicate
// const filter = f => a => a.filter(a)
const filter = f => a => {
  const ret = []
  let ii = 0
  let ll = a.length
  for (;ii < ll; ii++) {
    if (f(a[ii])) ret.push(a[ii])
  }
  return ret
}

// **magic**
const filterArrByValue = value => {
  // create a regular expression based on your search value
  // cache it for all filter iterations
  const reg = toReg(value)
  // filter your array of people
  return filter(
    // only return the results that match the regex
    hasValue(match(reg))
  )
}

// create a function to filter by the value 'bob'
const filterBob = filterArrByValue('Bob')

// ########################## UNIT TESTS ########################## //

console.assert('hasValue finds a matching value', !!hasValue(x => x === 'one')({ one: 'one' }))
console.assert('toReg is a regular expression', toReg('reg') instanceof RegExp)
console.assert('match finds a regular expression in a string', !!match(/test/)('this is a test'))
console.assert('filter filters an array', filter(x => x === true)([true, false]).length === 1)

// ##########################   RESULTS   ########################## //

console.log(
  // run your function passing in your people array
  'find bob',
  filterBob(people)
)

console.log(
  // or you could call both of them at the same time
  'find salmon',
  filterArrByValue('salmon')(people)
)

// ########################## BENCHMARKS ########################## //

// dmitry's first function
const filterBy = (term) => {
  const termLowerCase = term.toLowerCase()
  return (person) =>
    Object.keys(person)
      .some(prop => person[prop].toLowerCase().indexOf(termLowerCase) !== -1)
}

// dmitry's updated function
const escapeRegExp = (str) => // or better use 'escape-string-regexp' package
  str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")


const filterBy2 = (term) => {
  const re = new RegExp(escapeRegExp(term), 'i')
  return person => {
    for (let prop in person) {
      if (!person.hasOwnProperty(prop)) {
        continue;
      }
      if (re.test(person[prop])) {
        return true;
      }
    }
    return false;        
  }
}

// test stringify - incredibly slow
const fuzzyMatch = (collection, searchTerm) =>
  collection.filter((obj) => JSON.stringify(obj)
    .toLowerCase()
    .indexOf(searchTerm.toLowerCase()) !== -1
)

new Benchmark({ iterations: 1000000 })
  // test my function - fastest
  .add('synthet1c', function() {
    filterBob(people)
  })
  .add('dmitry', function() {
    people.filter(filterBy('Bob'))
  })
  .add('dmitry2', function() {
    people.filter(filterBy2('Bob'))
  })
  .add('guest', function() {
    fuzzyMatch(people, 'Bob')
  })
  .run()
<link rel="stylesheet" type="text/css" href="https://codepen.io/synthet1c/pen/WrQapG.css">
<script src="https://codepen.io/synthet1c/pen/WrQapG.js"></script>

Upvotes: 2

Doppio
Doppio

Reputation: 2188

You should give fusejs a shot. http://fusejs.io/ It has some interesting settings such as threshold, which allow some typo error (0.0 = perfect, 1.0 = match anything) and keys to specify any keys you want to search in.

const people = [
  { firstName: 'Bob', lastName: 'Smith', status: 'single' },
  { firstName: 'bobby', lastName: 'Suxatcapitalizing', status: 'single' },
  { firstName: 'Jim', lastName: 'Johnson', status: 'complicated' },
  { firstName: 'Sally', lastName: 'Fields', status: 'relationship' },
  { firstName: 'Robert', lastName: 'Bobler', status: 'single' },
  { firstName: 'Johnny', lastName: 'Johannson', status: 'complicated' },
  { firstName: 'Whaley', lastName: 'McWhalerson', status: 'relationship'
    rogueBonusKey: 'bob likes salmon' },
]

const fuseOptions = {
  caseSensitive: false,
  shouldSort: true,
  threshold: 0.2,
  location: 0,
  distance: 100,
  maxPatternLength: 32,
  minMatchCharLength: 1,
  keys: [
    "firstName",
    "lastName",
    "rogueBonusKey",
  ]
};


const search = (txt) => {
  const fuse = new Fuse(people, fuseOptions);
  const result = fuse.search(txt);
  return result;
}

Upvotes: 2

Dmitry Druganov
Dmitry Druganov

Reputation: 2348

If we assume that all properties are strings, then you might do in the following way

const people = [
  // ...
]

const searchString = 'Bob'

const filterBy = (term) => {
  const termLowerCase = term.toLowerCase()
  return (person) =>
    Object.keys(person)
      .some(prop => person[prop].toLowerCase().indexOf(termLowerCase) !== -1)
}

const found = people.filter(filterBy(searchString))

console.log(found)

Update: alternative solution with RegExp and more old-school :) but 2x faster

const people = [
  // ...
]

const searchString = 'Bob'

const escapeRegExp = (str) => // or better use 'escape-string-regexp' package
  str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&")


const filterBy = (term) => {
  const re = new RegExp(escapeRegExp(term), 'i')
  return person => {
    for (let prop in person) {
      if (!person.hasOwnProperty(prop)) {
        continue;
      }
      if (re.test(person[prop])) {
        return true;
      }
    }
    return false;        
  }
}

const found = people.filter(filterBy(searchString))

Upvotes: 7

guest271314
guest271314

Reputation: 1

If the entire matched object is expected result you can use for..of loop, Object.values(), Array.prototype.find()

consy searchString = "Bob";
const re = new RegExp(searchString, "i");
let res = [];
for (const props of Object.values(people))
  Object.values(props).find(prop => re.test(prop)) && res.push(props);

Upvotes: 1

Related Questions