3

UPDATE: While there is good value to the code provided in the answers below, an improved version of this question, and its answer, can be found here.

EDIT: Correcting the sample data object and simplifying (hopefully) the question

GOAL: Given the below object, a function should parse the object through all its nestings and return the values that correspond to the keypath string argument, which might be a simple string, or include bracketed/dotted notation. The solution should work in Angular (plain JavaScript, TypeScript, a library that works in Angular).

My object:

const response = {
  "id": "0",
  "version": "0.1",
  "interests": [ {
    "categories": ["baseball", "football"],
    "refreshments": {
      "drinks": ["beer", "soft drink"],
    }
  }, {
    "categories": ["movies", "books"],
    "refreshments": {
      "drinks": ["coffee", "tea"]
    }
  } ],
  "goals": [ {
    "maxCalories": {
      "drinks": "350",
      "pizza": "700",
    }
  } ],
}

The initial function was:

function getValues(name, row) {
  return name
    .replace(/\]/g, '') 
    .split('[')
    .map(item => item.split('.'))
    .reduce((arr, next) => [...arr, ...next], [])
    .reduce ((obj, key) => obj && obj[key], row);
}

So, if we run getValues("interests[refreshments][drinks]", response); the function should return an array with all applicable values: ["beer", "soft drink", "coffee", "tea"].

The above works fine for a simple string key. getRowValue("version", response) yields "0.1" as expected. But, getRowValue("interests[refreshments][drinks]", response) returns undefined.

I crawled through this and the many related links, but am having difficulty understanding how to deal with the complex nature of the object.

Joe H.
  • 147
  • 1
  • 10
  • Arrays are also objects. [`Array.isArray()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray) may help with that. – Ouroborus Dec 17 '20 at 19:00
  • @Ouroborus, yep - I know that will be involved - just can't quite see through the matrix on how to incorporate it. – Joe H. Dec 17 '20 at 19:06
  • `.reduce ((obj, key) => obj && obj[key], row);` is totally fine, this is the standard approach. But you're parsing the input string wrong - you can't just drop closing brackets! – Bergi Dec 17 '20 at 19:36
  • @Bergi, in the parsing, all brackets, and any dots, on the input arg are dropped. If the input arg is `"goals[maxCalories][drinks]"`, the result is `["goals", "maxCalories", "drinks"]` – Joe H. Dec 17 '20 at 19:44
  • But, I see what you're saying, I can add some logic to the final `.reduce()`, and am working on it. But, those nested arrays are my nemesis! – Joe H. Dec 17 '20 at 20:26
  • I really don't understand the requirements. Why should `newGetRowValues("interests.refreshments.drinks", response)` include `response.interests[0].drinks` (`["beer", "soft drink"]`) but not `response.goals.maxCalories.drinks` (`"350"`)? That it includes `response.interests[1].refreshements.drinks` (`["coffee", "tea"]`) makes sense to me, but I would expect it to take both the first two or neither. How do you distinguish? – Scott Sauyet Dec 17 '20 at 21:54
  • D-oh! My sample data has an error. it should be `interests.refreshments.drinks`. Accordingly, the now-correct data model is: `let response = { "id": "0", "version": "0.1", "interests": [ { "categories": ["baseball", "football"], "drinks": ["beer", "soft drink"], }, { "categories": ["movies", "books"], "refreshments": { "drinks": ["coffee", "tea"] } } ], "goals": [ { "maxCalories": { "drinks": "350", "pizza": "700", } } ], }` – Joe H. Dec 18 '20 at 12:31
  • Users would elect to display a given table column, which is mapped to the model. For example: `ID maps to "id"`, `Categories > "interests.categories" (array)`, `Drinks > "iinterests.refreshments.drinks (array nested in object)`, `Drink Calories > "goals.maxCalories.drinks" (object, nested in object, nested in array)`, and `Pizza Calories > "goals.maxCalories.pizza" (as above)` – Joe H. Dec 18 '20 at 12:35
  • That doesn't look like any change to the data. My question was more about why `"interests.refreshments.drinks"` included the drinks from `interests[0]`, which has no `refreshments` node. If we're going to include any `drinks` node, regardless of hierarchy, then why not the one inside `goals` as well? If we're not, how do we choose to include one and not the other? See my answer for one possible resolution. – Scott Sauyet Dec 18 '20 at 14:12
  • My apologies, flubbed it up again. the data is: `let response = { "id": "0", "version": "0.1", "interests": [ { "categories": ["baseball", "football"], "refreshments": { "drinks": ["beer", "soft drink"], } }, { "categories": ["movies", "books"], "refreshments": { "drinks": ["coffee", "tea"] } } ], "goals": [ { "maxCalories": { "drinks": "350", "pizza": "700", } } ], }` – Joe H. Dec 18 '20 at 15:38
  • which renders these possible selectors: `"id",` `"version",` `"interests.categories",` `"interests.refreshments.drinks",` `"goals.maxCalories.drinks",` `"goals.maxCalories.pizza"` – Joe H. Dec 18 '20 at 15:39
  • Yes, that makes more sense. I think my answer still stands. But it may not be at all what you're looking for. – Scott Sauyet Dec 18 '20 at 15:40
  • I think it's spot on. transposing to my other environment where my data lives. – Joe H. Dec 18 '20 at 15:46
  • 1
    @ScottSauyet, I've got it moved to my other environment, found all my typos, and can tell you it's a thing of beauty! Thanks a zillion! – Joe H. Dec 18 '20 at 19:16
  • Unfortunately, I was not able to get this to work in my Angular app. I have been trying to tweak it to get it to run, but I am having trouble understanding some of your code, especially the getPaths() and hasSubseq() functions. – Joe H. Jan 07 '21 at 17:42
  • Strings like `"interests[refreshments][drinks]"` does not represent valid JavaScript, which should be `'interests["refreshments"]["drinks"]'`, i.e. values - indexes must be either numbers or quoted strings, they cannot be some open variable name. – vitaly-t Mar 07 '21 at 23:56
  • You are correct, yet this is the string that is returned from the database and passed to the function. Thus, in the original question, the dance of the `replace.split.map.reduce` methods converts the input string into something more acceptable: `["interests", "refreshments", "drinks"]` In the answer below, this is handled much more elegantly in a one-liner: `name.split (/[[\].]+/g).filter(Boolean)` – Joe H. Mar 09 '21 at 12:47

2 Answers2

2

Here is a solution using object-scan.

The only tricky part is the transformation of the search input into what object-scan expects.

// const objectScan = require('object-scan');

const response = { id: '0', version: '0.1', interests: [{ categories: ['baseball', 'football'], refreshments: { drinks: ['beer', 'soft drink'] } }, { categories: ['movies', 'books'], refreshments: { drinks: ['coffee', 'tea'] } }], goals: [{ maxCalories: { drinks: '350', pizza: '700' } }] };

const find = (haystack, needle) => {
  const r = objectScan(
    [needle.match(/[^.[\]]+/g).join('.')],
    { rtn: 'value', useArraySelector: false }
  )(haystack);
  return r.length === 1 ? r[0] : r.reverse();
};

console.log(find(response, 'interests[categories]'));
// => [ 'baseball', 'football', 'movies', 'books' ]
console.log(find(response, 'interests.refreshments.drinks'));
// => [ 'beer', 'soft drink', 'coffee', 'tea' ]
console.log(find(response, 'goals[maxCalories][drinks]'));
// => 350
.as-console-wrapper {max-height: 100% !important; top: 0}
<script src="https://bundle.run/object-scan@13.8.0"></script>

Disclaimer: I'm the author of object-scan

vincent
  • 1,953
  • 3
  • 18
  • 24
  • The requirements were never entirely clear to me, but how much more effort would it be to do what my answer does, for instance having `find('interests.drinks')` return `['beer', 'soft drink', 'coffee', 'tea']` just as `find('interests.refreshments.drinks')` does, or having `find('drinks')` return `['beer', 'soft drink', 'coffee', 'tea', 350]`? I'm curious about the flexibility of object-scan, since the version so far, which requires much less code than mine, is also much less powerful. Would it be trivial to match? – Scott Sauyet Dec 29 '20 at 14:09
  • Agreed, the question was not clean. I used the last data set from the comments and the three requirements that were clear from the question. Reproducing your result requires only minimal changes - just replace the `needles` array in my code with `[\`**.${needle.match(/[^.[\]]+/g).join('.**.')}\`]`. Oh and do let me know if you have any questions on how that works! – vincent Dec 29 '20 at 16:07
  • 1
    That's very nice. I will have to spend some time looking at object-scan. Several of your previous answers have shown it modifying the input structure, and I'm fundamentally opposed to that. I thought that's what it was about. But clearly its central feature must be an object query tool, and I find that much more intriguing. I'll take a look. – Scott Sauyet Dec 29 '20 at 16:27
  • 1
    Thank you and that is correct. The central feature is performant object traversal based on search criteria (needles, filterFn, breakFn) with key focus on performance. And to address your concerns: Modifying existing data structures is more performant by nature and can always be "fixed" by deep cloning the input first (while the reverse is not true). That's the reasoning why I usually prefer to modify in my answers if both would be ok. – vincent Dec 29 '20 at 17:56
  • This is working perfectly in my testing as javascript. But, when I go to use it in my Angular app, I'm having difficulty as there is not a TypeScript Declarations File for the library, as far as I can tell. I was able to get @ScottSauyet 's answer working in my Angular Test environment (with a few hacks) - but, production doesn't seem to like the hacks. – Joe H. Dec 30 '20 at 20:08
  • Seems like everyone wants typescript these days. I'm in the process of adding it to all my npm packages, but still a while out – vincent Dec 30 '20 at 20:16
  • I'll keep an eye out for it. Meanwhile, I'm learning about how to write a typings.d.ts file that will allow me to use object-scan, even if it's just saying everything is an 'any' – Joe H. Dec 30 '20 at 20:20
  • Personally, I'm still avoiding TS as well as I can. The function in my answer above is relatively easy to type, but I write plenty that aren't, and TS still holds no appeal, so I'm not trying hard to make it easier for people to type my work. Perhaps some day... – Scott Sauyet Dec 30 '20 at 20:41
  • Unfortunately, I still can't get object-scan to work in Angular. – Joe H. Jan 07 '21 at 17:56
1

Update

After thinking about this over night, I've decided that I really don't like the use of coarsen here. (You can see below that I waffled about it in the first place.) Here is an alternative that skips the coarsen. It does mean that, for instance, passing "id" will return an array containing that one id, but that makes sense. Passing "drinks" returns an array of drinks, wherever they are found. A consistent interface is much cleaner. All the discussion about this below (except for coarsen) still applies.

// utility functions
const last = (xs) =>
  xs [xs.length - 1]

const endsWith = (x) => (xs) =>
  last(xs) == x

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

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

const hasSubseq = ([x, ...xs]) => ([y, ...ys]) =>
  y == undefined
    ? x == undefined
  : xs .length > ys .length
    ? false
  : x == y
    ? hasSubseq (xs) (ys)
  : hasSubseq ([x, ...xs]) (ys)


// helper functions
const findPartialMatches = (p, obj) =>
  getPaths (obj)
    .filter (endsWith (last (p)))
    .filter (hasSubseq (p))
    .flatMap (p => path (p) (obj))
  

const name2path = (name) => // probably not a full solutions, but ok for now
  name .split (/[[\].]+/g) .filter (Boolean)


// main function
const newGetRowValue = (name, obj) =>
  findPartialMatches (name2path (name), obj)


// sample data
let response = {id: "0", version: "0.1", interests: [{categories: ["baseball", "football"], refreshments: {drinks: ["beer", "soft drink"]}}, {categories: ["movies", "books"], refreshments: {drinks: ["coffee", "tea"]}}], goals: [{maxCalories: {drinks: "350", pizza: "700"}}]};

// demo
[
  'interests[refreshments].drinks',
  'interests[drinks]',
  'drinks',
  'interests[categories]',
  'goals',
  'id',
  'goals.maxCalories',
  'goals.drinks'
] .forEach (
  name => console.log(`"${name}" --> ${JSON.stringify(newGetRowValue(name, response))}`)
)
.as-console-wrapper {max-height: 100% !important; top: 0}

Original Answer

I still have some questions about your requirements. See my comment on the question for details. I'm making an assumption here that your requirements are slightly more consistent than suggested: mostly that the nodes in your name must be present, and the nesting structure must be as indicated, but that there might be intermediate nodes not mentioned. Thus "interests.drinks" would include the values of both interests[0].drinks and "interests[1].refreshments.drinks", but not of "goals.maxCategories.drinks", since that does not include any "interests" node.

This answer also has a bit of a hack: the basic code would return an array for any input. But there are times when that array has only a single value, and usually we would want to return just that value. That is the point of the coarsen function used in findPartialMatches. It's an ugly hack, and if you can live with id yielding ["0"] in an array, I would remove the call to coarsen.

Most of the work here uses arrays for the path rather than your name value. I find it much simpler, and simply convert to that format before doing anything substantial.

Here is an implementation of this idea:

// utility functions
const last = (xs) =>
  xs [xs.length - 1]

const endsWith = (x) => (xs) =>
  last(xs) == x

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

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

const hasSubseq = ([x, ...xs]) => ([y, ...ys]) =>
  y == undefined
    ? x == undefined
  : xs .length > ys .length
    ? false
  : x == y
    ? hasSubseq (xs) (ys)
  : hasSubseq ([x, ...xs]) (ys)


// helper functions
const coarsen = (xs) => 
  xs.length == 1 ? xs[0] : xs

const findPartialMatches = (p, obj) =>
  coarsen (getPaths (obj)
    .filter (endsWith (last (p)))
    .filter (hasSubseq (p))
    .flatMap (p => path (p) (obj))
  )

const name2path = (name) => // probably not a full solutions, but ok for now
  name .split (/[[\].]+/g) .filter (Boolean)


// main function
const newGetRowValue = (name, obj) =>
  findPartialMatches (name2path (name), obj)


// sample data
let response = {id: "0", version: "0.1", interests: [{categories: ["baseball", "football"], refreshments: {drinks: ["beer", "soft drink"]}}, {categories: ["movies", "books"], refreshments: {drinks: ["coffee", "tea"]}}], goals: [{maxCalories: {drinks: "350", pizza: "700"}}]};

// demo
[
  'interests[refreshments].drinks',
  'interests[drinks]',
  'drinks',
  'interests[categories]',
  'goals',
  'id',
  'goals.maxCalories',
  'goals.drinks'
] .forEach (
  name => console.log(`"${name}" --> ${JSON.stringify(newGetRowValue(name, response))}`)
)
.as-console-wrapper {max-height: 100% !important; top: 0}

We start with two simple utility functions:

  • last returns the last element of an array

  • endsWith simply reports if the last element of an array equals a test value

Then a few more substantial utility functions:

  • path takes an array of node names, and an object and finds the value of at that node path in an object.

  • getPaths takes an object and returns all the paths found in it. For instance, the sample object will yield something like this:

    [
      ["id"],
      ["version"],
      ["interests"],
      ["interests", "0"],
      ["interests", "0", "categories"],
      ["interests", "0", "categories", "0"],
      ["interests", "0", "categories", "1"],
      ["interests", "0", "drinks"],
      // ...
      ["goals"],
      ["goals", "0"],
      ["goals", "0", "maxCalories"],
      ["goals", "0", "maxCalories", "drinks"],
      ["goals", "0", "maxCalories", "pizza"]
    ]
    
  • hasSubseq reports whether the elements of the first argument can be found in order within the second one. Thus hasSubseq ([1, 3]) ([1, 2, 3, 4) returns true, but hasSubseq ([3, 1]) ([1, 2, 3, 4) returns false. (Note that this implementation was thrown together without a great deal of thought. It might not work properly, or it might be less efficient than necessary.)

After that we have three helper functions. (I distinguish utility functions from helper functions this way: utility functions may be useful in many places in the project and even across projects. Helper functions are specific to the problem at hand.):

  • coarsen was discussed above and it simply turns single-element arrays into scalar values. There's a good argument for removing this altogether.

  • findPartialMatches is central. It does what our main function is designed to do, but using an array of node names rather than a dot/bracket-separated string.

  • name2path converts the dot/bracket-separated string into an array. I would move this up to the utility section, except that I'm afraid that it may not be as robust as we would like.

And finally, the main function simply calls findPartialMatches using the result of name2path on the name parameter.

The interesting code is findPartialMatches, which gets all the paths in the object, and then filters the list to those that end with the last node of our path, then further filters these to the ones that have our path as a subsequence, retrieves the values at each of these paths, wraps them in an array, and then calls the unfortunate coarsen on this result.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • Updated with a simpler version of `getPaths`. The original was useful for cases where we wanted to use paths to rebuild objects. But it's not necessary here, and it complicated the implementation. – Scott Sauyet Jan 08 '21 at 21:43