DEV Community

Discussion on: Search through a JSON object using JavaScript

Collapse
 
qm3ster profile image
Mihail Malo • Edited

Without changing the structure, I would do the following:

const charSeparator = "/"
/**
 * searches deep into an object recursively...
 * @param {Object} obj object to be searched
 * @param {any} searchValue the value/key to search for
 * @param {boolean} [valuesOnly=false] whether to skip comparing object keys
 * @param {number} [maxDepth=20] maximum recursion depth (to avoid "Maximum call stack size exceeded")
 * @returns {string[]} Paths on the object to the matching results
 */
function getValuePathInObject(
  obj,
  searchValue,
  valuesOnly = false,
  maxDepth = 20
) {
  if (!maxDepth) return []

  const paths = []

  for (const [curr, currElem] of Object.entries(obj)) {

    if (!valuesOnly && curr === searchValue) {
      // To search for property name too...
      paths.push(curr)
    }

    if (typeof currElem == "object") {
      // object is "object" and "array" is also in the eyes of `typeof`
      // search again :D
      const deepPaths = getValuePathInObject(
        currElem,
        searchValue,
        valuesOnly,
        maxDepth - 1
      )
      const currDir = curr + charSeparator
      for (const path of deepPaths) {
        paths.push(currDir + path)
      }
      continue
    }
    // it's something else... probably the value we are looking for
    // compares with `searchValue`
    if (currElem === searchValue) {
      // return index AND/OR property name
      paths.push(curr)
    }
  }
  return paths
}
  1. Use const and let, never var.
  2. Use for of loops instead of imperative key and numeric index iteration.
  3. Remove the debug logging
  4. Hoist the constant charSeparator out of the function (or you could inline the "/" literal)
  5. Only concatenate curr and charSeparator once, not in the loop.
  6. Remove unused variable i.
  7. Use early return (or rather, continue).
  8. Use JSDoc, LMAO xD
  9. Use down-counting depth limit to pass only one number.
  10. Use defaults in the function parameters. These only check for undefined, not truthiness, so passing false and 0 won't trigger them.
Collapse
 
qm3ster profile image
Mihail Malo • Edited

Otherwise, I'd use an inner recursive function so that I can do option parsing once, and then close over one array I will always append to, to avoid allocating intermediate arrays:

/**
 * searches deep into an object recursively...
 * @param {Object} obj object to be searched
 * @param {any} searchValue the value/key to search for
 * @param {Object} [options]
 * @param {boolean} options.[searchKeys] whether to search object keys as well as values. Defaults to `true` if `serchValue` is a string, `false` otherwise.
 * @param {number} options.[maxDepth=20] maximum recursion depth (to avoid "Maximum call stack size exceeded")
 * @returns {string[]} Paths on the object to the matching results
 */
const findPaths = (
  obj,
  searchValue,
  { searchKeys = typeof searchValue === "string", maxDepth = 20 } = {}
) => {
  const paths = []
  const notObject = typeof searchValue !== "object"
  const gvpio = (obj, maxDepth, prefix) => {
    if (!maxDepth) return

    for (const [curr, currElem] of Object.entries(obj)) {
      if (searchKeys && curr === searchValue) {
        // To search for property name too ...
        paths.push(prefix + curr)
      }

      if (typeof currElem === "object") {
        // object is "object" and "array" is also in the eyes of "typeof"
        // search again :D
        gvpio(currElem, maxDepth - 1, prefix + curr + "/")
        if (notObject) continue
      }
      // it's something else... probably the value we are looking for
      // compares with "searchValue"
      if (currElem === searchValue) {
        // return index AND/OR property name
        paths.push(prefix + curr)
      }
    }
  }
  gvpio(obj, maxDepth, "")
  return paths
}

Here, I'm also using an options object for convenience, with a smart default for searchKeys, since object keys are always strings, even in arrays:

console.log(findPaths([[[]],[[]]],0)) // []
console.log(findPaths([[[]],[[]]],'0')) // [ '0', '0/0', '1/0' ]

I'm also building the path string with no duplication. This includes eliminating the inner loop, since we never return an intermediary array back up. The reason prefix + curr occurs in 3 places is because in a real search, on most keys none of those conditions will happen, and almost never will two happen together.

Collapse
 
killants profile image
killants • Edited

Not used to see many arrow function (without being small ones, in array.map for example) so i got a bit confused with the parameter line :
{ searchKeys = typeof searchValue === "string", maxDepth = 20 } = {}

Would you mind to explain in a few words why it is there? :)

Thank you in advance.

Thread Thread
 
qm3ster profile image
Mihail Malo

Sure, this doesn't depend on it being an arrow function, can be in a function just as well.

This is the equivalent function:

function fn(arg) {
  if (arg === undefined) arg = {}
  let searchKeys = arg.searchKeys
  if (searchKeys === undefined) searchKeys = typeof searchValue === "string"
  let maxDepth = arg.maxDepth
  if (maxDepth === undefined) maxDepth = 20
  /* ... */
}

First, we replace the point access with destructuring:

function fn(arg) {
  if (arg === undefined) arg = {}
  let { searchKeys, maxDepth } = arg
  if (searchKeys === undefined) searchKeys = typeof searchValue === "string"
  if (maxDepth === undefined) maxDepth = 20
  /* ... */
}

Next, we replace the conditional expressions for defaults with defaults in destructuring AND default in parameter:

function fn(arg = {}) {
  const { // we can use const now because we won't be reassigning
    searchKeys = typeof searchValue === "string",
    maxDepth = 20
  } = arg
  /* ... */
}

Finally, to avoid having to think of a name for the short-lived arg binding, we can just YEET it right into the parameter list:

function fn({
  searchKeys = typeof searchValue === "string",
  maxDepth = 20
} = {}) {
  /* ... */
}

We could write it like this:

function fn(
  { 
    searchKeys = typeof searchValue === "string",
    maxDepth = 20
  } = {
    searchKeys: typeof searchValue === "string",
    maxDepth: 20
  }
) {
  /* ... */
}

But that's just more verbose. Destructuring an empty object (if undefined is provided) is perfectly adequate, and gives defaults for all keys.

function fn(
  searchValue, // this is the v below that we are depending on, it has to come first.
  { searchKeys = typeof searchValue === "string", maxDepth = 20 } = {}
) {
  /* ... */
}
Collapse
 
qm3ster profile image
Mihail Malo • Edited

Finally, at the cost of a tiny bit of memory, you can keep a Set of visited objects so you can prevent infinite recursion without a counter.

Before:

const obj = {
  a: 0,
  b: 1,
  c: [[]]
}
const obj2 = {t:obj}
obj.c[0].push(obj2, 't')
console.log(findPaths(obj,"t"));
[ 'c/0/0/t',
  'c/0/0/t/c/0/0/t',
  'c/0/0/t/c/0/0/t/c/0/0/t',
  'c/0/0/t/c/0/0/t/c/0/0/t/c/0/0/t',
  'c/0/0/t/c/0/0/t/c/0/0/t/c/0/0/t/c/0/0/t',
  'c/0/0/t/c/0/0/t/c/0/0/t/c/0/0/t/c/0/1',
  'c/0/0/t/c/0/0/t/c/0/0/t/c/0/1',
  'c/0/0/t/c/0/0/t/c/0/1',
  'c/0/0/t/c/0/1',
  'c/0/1' ]

After:

Output:

[ 'c/0/0/t', 'c/0/1' ]

Code:

/**
 * searches deep into an object recursively...
 * @param {Object} obj object to be searched
 * @param {any} searchValue the value/key to search for
 * @param {boolean} [searchKeys] whether to search object keys as well as values. Defaults to `true` if `serchValue` is a string, `false` otherwise.
 * @returns {string[]} Paths on the object to the matching results
 */
const findPaths = (
  obj,
  searchValue,
  searchKeys = typeof searchValue === "string"
) => {
  const paths = []
  const visited = new Set()
  const notObject = typeof searchValue !== "object"
  const gvpio = (obj, prefix) => {
    for (const [curr, currElem] of Object.entries(obj)) {
      if (searchKeys && curr === searchValue) {
        paths.push(prefix + curr)
      }

      if (typeof currElem === "object") {
        if (visited.has(currElem)) continue
        visited.add(currElem)
        gvpio(currElem, prefix + curr + "/")
        if (notObject) continue
      }
      if (currElem === searchValue) {
        paths.push(prefix + curr)
      }
    }
  }
  gvpio(obj, "")
  return paths
}

Disclaimer:

Be careful though, it won't always include the shortest path!

const obj = {
  t: "t"
}
const obj2 = {
  a: [{ a: [{ a: [{ obj }] }] }],
  b: obj
}
console.log(findPaths(obj2, "t"));
[ 'a/0/a/0/a/0/obj/t', 'a/0/a/0/a/0/obj/t' ] // :(
Collapse
 
killants profile image
killants

Hasn't use "let" before to declare variables, will definitely use now ( Thanks to Sarah Chima for her post "dev.to/sarah_chima/var-let-and-con..." )! :D

Is "for of" more optimized for this kind of methods (recursive), instead of indexed ones ?

I had that "charSeparator" declared inside because at the time i made it i though leaving the option to choose the char used to be received in parameter (but then i just forgot it XD)

That variable "i" .... I just can't even remember why was there ?? :/ My bad !

Tonight i won't be able, but tomorrow right after work, will fix it ! ;)

Thanks a lot for your feedback Mihail :D

Collapse
 
qm3ster profile image
Mihail Malo

If you look in the replies to my above comment, I replied to myself (and then replied again to that comment) with new versions of the code.