Skip to content Skip to sidebar Skip to footer

How Can I Retrieve Dynamically Specified, Arbitrary And Deeply Nested Values From A Javascript Object Containing Strings, Objects, And Arrays?

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 da

Solution 1:

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' } }] };

constfind = (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}
<scriptsrc="https://bundle.run/object-scan@13.8.0"></script>

Disclaimer: I'm the author of object-scan

Solution 2:

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 functionsconstlast = (xs) =>
  xs [xs.length - 1]

constendsWith = (x) => (xs) =>last(xs) == x

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

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

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


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

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


// main functionconstnewGetRowValue = (name, obj) =>
  findPartialMatches (name2path (name), obj)


// sample datalet 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 functionsconstlast = (xs) =>
  xs [xs.length - 1]

constendsWith = (x) => (xs) =>last(xs) == x

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

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

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


// helper functionsconstcoarsen = (xs) => 
  xs.length == 1 ? xs[0] : xs

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

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


// main functionconstnewGetRowValue = (name, obj) =>
  findPartialMatches (name2path (name), obj)


// sample datalet 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.

Post a Comment for "How Can I Retrieve Dynamically Specified, Arbitrary And Deeply Nested Values From A Javascript Object Containing Strings, Objects, And Arrays?"