1

In short; How would I be able to retrieve all values of a shared key in multiple objects?

Longer: I've been given the following objects, stored in an array called collection:

[
    names: {
        "one": "square";
        "two": {
            "three": "circle"
            "four": {
                "five": "triangle"
                }
            }
        }
    shapes: {
        "one": "[]";
        "two": {
            "three": "()"
            "four": {
                "five": "/\"
                }
            }
        }
]

I've made a menu system in Angular(v8.x.x) that reads the keys from the names objects. When I click on the menu item for "circle", I hope to obtain the key & value pair their paths for use in an editing window. This would need to happen for each item. Ex:

onClick(menuItem){
    const paths = collection.search(menuItem.key)
    console.log(paths) \\ expecting: ["names.two.three", "shapes.two.three"]
    openEditor(paths)
}

openEditor(paths){
    for(path in paths){
        display.Name(path.key)
        inputfield.value(path.value)
    }
|----------
|three: 
|circle
|----------
|three:
|()
|----------

I've attempted to create a recursive function myself but so far haven't achieved any feasible result. I have also tried Scott's amazing examples, although angular/typescript unfortunately throws an error on the definition of ps in assocPath():

Argument of type 'any[]' is not assignable to parameter of type '[any, ...any[]]'.
      Property '0' is missing in type 'any[]' but required in type '[any, ...any[]]'.

Additionally, I have looked to these answers as well:

1: StackOverflow: Javascript/JSON get path to given subnode?

2: StackOverflow: Get the “path” of a JSON object in JavaScript

The 1st has an error regarding path being a block-scoped variable being used before its declaration and I'm currently troubleshooting the 2nd in my scenario.

5
  • where is your try? Commented Oct 10, 2019 at 9:37
  • I've tried a number of for ... in ... loops, though most would return undefined values, perhaps due to trying to use indices, or that the variables aren't passed properly. I'm a tad lost in that regard. Commented Oct 10, 2019 at 9:39
  • sounds like you need a recursive function. can you give an example input and output? Commented Oct 10, 2019 at 13:52
  • correct. the input would be a collection object/array that in turn contains three (or more) objects. e.g. if the output should be the names/path of key2-1, the function would have to search through one.key1.key2.key2-1 = john and two.key1.key2.key2-1 = alice. the paths and keys do not change, but the "target" key depends on whatever the user selects in the menu (said menu reflects the keys of the objects). I have tried to make a recursive function, but it struggles to continue past key1, might be due to bracket notation not being ideal for this situation. (one[key1][key2][key2-1]) Commented Oct 10, 2019 at 15:00
  • What is the output structure you're looking for? Also, please do share some of what you've tried. Even it it's incomplete, it often helps others understand what it is you're trying to do. Commented Oct 10, 2019 at 15:47

2 Answers 2

1

Update

I clearly was cutting and pasting some code from elsewhere when I originally wrote this, as I included two functions that weren't being used at all. They're removed now.

I also added below an explanation of how I might, step-by-step, convert one of these functions from an ES5 style to the modern JS version in the answer. I believe ES6+ is a major improvement, and I like the function as I wrote it much better, but for those learning modern JS, such an explanation might be useful.

The updated original answer follows and then these transformation steps.


It's really unclear to me what you're looking for. My best guess is that you want to accept an array of objects like the above and return a function that takes a value like "circle" or "()" and returns the path to that value on one of those objects, namely ['two', 'three']. But that guess could be way off.

Here's a version that does this based on a few reusable functions:

// Helpers
const path = (ps = [], obj = {}) =>
  ps .reduce ((o, p) => (o || {}) [p], obj)

const findLeafPaths = (o, path = [[]]) => 
  typeof o == 'object'
    ? Object .entries (o) .flatMap (
        ([k, v]) => findLeafPaths (v, path).map(p => [k, ...p])
      ) 
    : path


// Main function
const makeSearcher = (xs) => {
  const structure = xs .reduce (
    (a, x) => findLeafPaths (x) .reduce ((a, p) => ({...a, [path (p, x)]: p}), a),
    {}
  )
  return (val) => structure[val] || [] // something else? or throw error?
}


// Demonstration
const objs = [
  {one: "square", two: {three: "circle", four: {five: "triangle"}}}, 
  {one: "[]", two: {three: "()", four: {five: "/\\"}}}
]

const searcher = makeSearcher(objs)

console .log (searcher ('()'))        //~> ['two', 'three']
console .log (searcher ('circle'))    //~> ['two', 'three']
console .log (searcher ('triangle'))  //~> ['two', four', 'five']
console .log (searcher ('[]'))        //~> ['one']
console .log (searcher ('heptagon'))  //~> []
        

We start with two helper functions, path, and findLeafPaths. These are all reusable functions. The first borrows its API from Ramda, although this is a separate implementation:

  • path accepts a list of nodes (e.g. ['two', 'three']) and an object and returns the value at that path if all the nodes along the way exist

  • findLeafPaths takes an object and viewing it as a tree, returns the paths of all leaf nodes. Thus for your first object, it would return [['one'], ['two', 'three'], ['two', 'four', 'five']]. Again we ignore arrays, and I'm not even sure what we would need to do to support them.

The main function is makeSearcher. It takes an array of objects like this:

[
  {one: "square", two: {three: "circle", four: {five: "triangle"}}}, 
  {one: "[]", two: {three: "()", four: {five: "/\\"}}}
]

and converts it them into a structure that looks like this:

{
  'square'   : ['one']
  'circle'   : ['two', 'three']
  'triangle' : ['two', 'four', 'five']
  '[]'       : ['one']
  '()'       : ['two', 'three']
  '/\\'      : ['two', 'four', 'five']
}

and then returns a function that simply looks up the values from this structure.

I have some vague suspicions that this code is not quite as well thought-out as I like, since I can't find a better name for the helper object than "structure". Any suggestions would be welcome.

Transforming ES5 to modern JS

Here we show a series of transformations from ES5 to modern Javascript code. Note that I actually wrote these in the other order, as the ES6+ is now what come naturally to me after working in it for a few years. This may be helpful for those coming from ES5 backgrounds, though.

We're going to convert a version of findLeafPaths. Here is a version that I think skips all ES6+ features:

const findLeafPaths = function (o, path) {
  if (typeof o == 'object') {
    const keys = Object .keys (o)
    const entries = keys .map (key => [key, o [key]])
    const partialPaths = entries .map ( function ([k, v]) {
      const paths = findLeafPaths (v, path || [[]])
      return paths .map (function(p) {
        return [k].concat(p)
      })
    })
    return partialPaths.reduce(function(a, b) {
      return a.concat(b)
    }, [])
  }
  return path || [[]]
}

The first thing we do is use Object.entries to replace the dance with getting the object's keys and then mapping those to get the [key, value] pairs:

const findLeafPaths = function (o, path) {
  if (typeof o == 'object') {
    const entries = Object .entries (o)
    const partialPaths = entries .map (function ([k, v]) {
      const paths = findLeafPaths (v, path || [[]])
      return paths .map (function(p) {
        return [k] .concat (p)
      })
    })
    return partialPaths.reduce(function (a, b) {
      return a .concat (b)
    }, [])
  }
  return path || [[]]
}

Next, the pattern of mapping, then flattening by reducing with concatenation has a a built-in Array method, flatMap. We can simplify by using that:

const findLeafPaths = function (o, path) {
  if (typeof o == 'object') {
    const entries = Object .entries (o)
    return entries .flatMap (function ([k, v]) {
      const paths = findLeafPaths (v, path || [[]])
      return paths .map (function(p) {
        return [k] .concat (p)
      })
    })
  }
  return path || [[]]
}

Now we can tweak this to take advantage of the modern spread syntax in place of concat:

const findLeafPaths = function (o, path) {
  if (typeof o == 'object') {
    const entries = Object .entries (o)
    return entries .flatMap ( function ([k, v]) {
      const paths = findLeafPaths (v, path || [[]])
      return paths .map (function(p) {
        return [k, ...p]
      })
    })
  }
  return path || [[]]
}

Arrow functions will simplify things further. Here we replace the innermost function call with an arrow:

const findLeafPaths = function (o, path) {
  if (typeof o == 'object') {
    const entries = Object .entries (o)
    return entries .flatMap ( function ([k, v]) {
      const paths = findLeafPaths (v, path || [[]])
      return paths .map (p => [k, ...p])
    })
  }
  return path || [[]]
}

We're repeating that path || [[]] expression in two places. We could use a default parameter to only have one:

const findLeafPaths = function (o, path = [[]]) {
  if (typeof o == 'object') {
    const entries = Object .entries (o)
    return entries .flatMap ( function ([k, v]) {
      return findLeafPaths (v, path) .map (p => [k, ...p])
    })
  }
  return path
}

Now we replace the next function expression (supplied to entries.flatmap()) with an arrow:

const findLeafPaths = function (o, path = [[]]) {
  if (typeof o == 'object') {
    const entries = Object .entries (o)
    return entries .flatMap (
      ([k, v]) => findLeafPaths (v, path) .map (p => [k, ...p])
    )
  }
  return path
}

entries is a temporary variable that we use only once in the line after it's defined. We can remove it easily:

const findLeafPaths = function (o, path = [[]]) {
  if (typeof o == 'object') {
    return Object .entries (o) .flatMap (
      ([k, v]) => findLeafPaths (v, path) .map (p => [k, ...p])
    )
  }
  return path
}

From a functional perspective, working with expressions is preferable to working with statements. They are more susceptible to analysis and they don't depend on external ordering. Hence, I will choose a conditional expression ("ternary statement") to an if-else one. So I prefer this version:

const findLeafPaths = function (o, path = [[]]) {
  return typeof o == 'object'
    ? Object .entries (o) .flatMap (
        ([k, v]) => findLeafPaths (v, path) .map (p => [k, ...p])
      ) 
    : path
}

Finally, we can replace the outermost function expression with another arrow function to get the version in the answer above:

const findLeafPaths = (o, path = [[]]) => 
  typeof o == 'object'
    ? Object .entries (o) .flatMap (
        ([k, v]) => findLeafPaths (v, path) .map (p => [k, ...p])
      ) 
    : path

Obviously we could do the same sort of thing with path and makeSearcher as well.

Note that every step of this reduced the line or character count of the function. That is nice, but it is not at all the most important point. More relevant is that each version is arguably simpler than the one preceding it. This does not mean that it's more familiar, only that fewer ideas are being twined together. (Rich Hickey's Simple Made Easy talk does a great idea of explaining the difference between these often-confused notions.)

I work often with junior developers, and getting them through this transition is important to the growth of their skills. There were no difficult steps in there, but the end result is substantially simpler than the original. After some time, writing directly in this style can become second-nature.

Sign up to request clarification or add additional context in comments.

11 Comments

My sincere apologies for the unclear descriptions, I've read over it myself a few times and couldn't quite interpret it well either. You did have the right interpretation, and I've been trying to fit your code in as well, albeit with some difficulty. (angular + typescript are rather picky, after all) I'd say "structure" is good for now, can't really think of a suitable alternative either.
@eymas: Added a long explanation. Also removed some functions I must have pasted from another question, ones not actually being used. Sorry about that.
The long explanation about matches up with what I had managed to extract, and retains the functionality quite well! Thank you for the awesome support! Personally, I would prefer the shorter notation as well.
Rather than adding a check, if this is something you need to support, then presumably you should be returning arrays every time, whether there are no paths, one path, or multiple paths. That can be done by replacing the (a, p) => ({...a, [path(p, x)]: p}) function passed to reduce there with something like (a, p) => ({...a, [path (p, x)]: [...(a[path(p, x)] || []), p]}) or equivalently, (a, p) => ({...a, [path (p, x)]: (a[path(p, x)] || []).concat([p])}).
That does make more sense, yeah. thanks for the quick reply! It is something I would need to support, as well as being able to find empty strings in the data object, but I should be able to resolve that myself.
|
0

I've solved the current tripping point;

const assocPath = ([p = undefined, ...ps], val, obj) => 
  p == undefined
    ? obj
    : assoc (p, ps.length ? assocPath ([ps], val, obj[p] || {}) : val, obj)

and the function(s) now work as intended. The result from searcher() can additionally return a single path if you only give the value you wish to find. e.g. searcher("circle") returns: ["one", "two", "three"].

3 Comments

That's a bit odd. assocPath(['a', 'b', 'c'], 42, {}) //=> {"a": {"b,c": 42}}, where the original would return what I would usually expect: {"a": {"b": {"c": 42}}}. assoc is designed to work with single strings, at the root level.
Also, given the input in the question, the correct path to "circle" should be ["two", "three"], correct? Or do you need to somehow know which array contained the value? If you did, then it might be something like [0, "two", "three"], which I imagine would actually be a simplification of my solution.
It is indeed strange. Though I couldn't find the cause myself, the current solution seems to work just fine, and by iterating over each object in the array, the path that the function currently returns is sufficient enough. The next step would be to see if I can reuse this path to alter any of the values found at the path (e.g. changing circle to orb while retaining ())

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.