How can I retrieve dynamically specified, arbitrary and deeply nested values from a Javascript object containing strings, objects, and arrays?

安稳与你 提交于 2021-01-27 13:15:50

问题


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.


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

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




回答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 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.



来源:https://stackoverflow.com/questions/65345304/how-can-i-retrieve-dynamically-specified-arbitrary-and-deeply-nested-values-fro

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!