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.

eymas
  • 105
  • 2
  • 15
  • where is your try? – sa_n__u Oct 10 '19 at 09: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. – eymas Oct 10 '19 at 09:39
  • sounds like you need a recursive function. can you give an example input and output? – Uzi Oct 10 '19 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]`) – eymas Oct 10 '19 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. – Scott Sauyet Oct 10 '19 at 15:47

2 Answers2

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.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • 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 Oct 15 '19 at 11:11
  • Well, the description was good enough that I managed to get it right. Best of luck with the Typescript; I avoid it as much as I can, myself. I always feel that if I can't name my functions or important objects properly, I've missed something. But sometimes I just leave it at that. – Scott Sauyet Oct 15 '19 at 12:27
  • Out of curiosity. If I were to "unfold" these ternary functions, I found that `findLeafPaths` can't function anymore (it would return undefined either on itself, or in the path results). How would I be able to resolve that? As I intend to document the workings of these functions for future reference. – eymas Oct 25 '19 at 12:17
  • I'm not sure what you mean by `unfold` here. Is it simply something like `assoc = (prop) => (val) => (obj) => ({...obj, [prop]: val})`? – Scott Sauyet Oct 27 '19 at 11:34
  • I found it a weird word to use as well, what I wanted to say was to convert the ternary operators etc. into if statements, in an attempt to make it readable for other (in)experienced devs. I've already attempted this but some `return` values can't quite be caught that way. – eymas Oct 28 '19 at 08:46
  • 1
    @eymas: Added a long explanation. Also removed some functions I must have pasted from another question, ones not actually being used. Sorry about that. – Scott Sauyet Oct 28 '19 at 14:09
  • 1
    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. – eymas Oct 28 '19 at 15:25
  • Seems I found a major issue that I'm currently debugging; if there are two or more keys with the same name (e.g. two `circle`s), only the last instance's value/path gets returned by the `searcher`. I didn't stumble on this until I noticed some values were _too_ similar. Possible solution is to handle duplicate entries in some way by storing the path separately, not sure how that will fit in the "catalog" func. if both paths have the same value... If I find a solution, I'll post it, but in the meantime I thought I'll notify it here, just in case. – eymas Nov 19 '19 at 12:12
  • I think this occurs on the very last piece of this chain `xs.reduce((a, x) => findLeafPaths(x).reduce((a, p) => ({...a, [path(p, x)]: p}), a), {})` where the last instance of `a` contains the 1st path instance to `circle` before it gets overwritten by the 2nd instance when that appears. might have to find a way to implement a check there. – eymas Nov 19 '19 at 12:50
  • 1
    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])})`. – Scott Sauyet Nov 19 '19 at 12:59
  • 1
    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. – eymas Nov 19 '19 at 13:18
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"].

eymas
  • 105
  • 2
  • 15
  • 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. – Scott Sauyet Oct 15 '19 at 15:41
  • 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. – Scott Sauyet Oct 15 '19 at 15:47
  • 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 `()`) – eymas Oct 17 '19 at 13:26