1

What I am hoping achieve is to use recursion or other method to dynamically create a specific output based on the json input. Below is just an example of some json and the format I hope to accomplish.

  "id": "0001",
  "type": "donut",
  "name": "Cake",
  "image": [
    {
      "url": "images/0001.jpg",
      "width": 200,
      "height": 200
    },
    {
      "url": "images/0002.jpg",
      "width": 300,
      "height": 300
    }
  ],
  "thumbnail": {
    "url": "images/thumbnails/0001.jpg",
    "width": 32,
    "height": 32
  }
}

Build an array of the keys using the following format.

["id","type","name","image[0].url","image[0].width","image[0].height","image[1].url","image[1].width","image[1].height","thumbnail.url","thumbnail.width","thumbnail.height"]

After building the array I would like to then iterate through the array and build a string like follows.

[ "id" ; $id ; JSONString ];[ "type" ; $type ; JSONString ];[ "name" ; $name ; JSONString ];[ "image[0].url" ; $image.url[0] ; JSONString ];[ "image[0].width" ; $image.width[0] ; JSONString ];[ "image[0].height" ; $image.height[0] ; JSONString ];[ "image[1].url" ; $image.url[1] ; JSONString ]...

So far I have been able to get this working on fairly simple JSON but have failed to get anything I have tried working with more complex structures.

JS Code : Edited

    var json = '{"id":"0001","type":"donut","name":"Cake","image":[{"url":"images/0001.jpg","width":200,"height":200},{"url":"images/0002.jpg","width":300,"height":300}],"thumbnail":{"url":"images/thumbnails/0001.jpg","width":32,"height":32}}';
    var obj = JSON.parse(json);


    // Recursion through the json 
    function getKeys(object) {
      return Object
        .entries(object)
        .reduce((r, [k, v]) =>
          r.concat(v && typeof v === 'object'
            ? getKeys(v).map(sub => [k].concat(sub))
            : k
          ),
          []
        );
    }

    function buildFM(object) {

      var objLength = object.length;
      var i = 1;
      var str = '';
      for (x of object) {
        var nodes = x.split(/\.(?=[^\.]+$)/);
        if (i == objLength) {
          str += '[ "' + x + '" ; $' + nodes[nodes.length - 1] + ' ; JSONString ]';
        } else {
          str += '[ "' + x + '" ; $' + nodes[nodes.length - 1] + ' ; JSONString ] ; ';
        }
        i++;
      }
      return str;
    }

    // Result from Recursion  
    result = getKeys(obj);
    console.log(result);


    // Setup Map of JSON for creating FM function
    var fmMap;
    fmMap = result.map(a => a.join('.'));
    console.log(fmMap);

    //  Build FM Elements
    var fmFX = '';
    var fmFX = buildFM(fmMap);
    console.log(fmFX);

Using the following JSON this method works fine.

var json = '{"fieldData":{"City":"New York","FirstName":"John","ID":"001","LastName":"Doe","State":"NY","Zip":"10005"}}'; 

On the more complex examples I have tried I get the following error.

Error:

Uncaught TypeError: a.join is not a function
    at jsonRecursion.html:216
    at Array.map (<anonymous>)
    at jsonParsing.html:216

How do I handle more complex json to get the array of keys like exampled?

jimrice
  • 452
  • 5
  • 17
  • 1
    can you give example of more complex json where it fails? – mss May 21 '20 at 02:34
  • `.join` is not a function means that it's not an array, which means you need to check which one of your mapped values aren't an array – A. L May 21 '20 at 02:48
  • could you also put your `JS Code` into a snippet? When I tried pasting your code into a snippet, I got a "cannot get length of undefined` error. Your example may need fixing – A. L May 21 '20 at 02:51
  • 1
    A. L. Edited the JS and put into a snippet. – jimrice May 21 '20 at 03:02
  • @jimrice i made an edit to `pathToString` that i think you will like – Mulan May 21 '20 at 15:59
  • This is not a duplicate of that one, but some of the techniques from https://stackoverflow.com/questions/56066101 would probably be useful. – Scott Sauyet May 21 '20 at 17:39
  • Is there a typo in the changing location of the brackets between the first two properties of `[ "image[0].url" ; $image.url[0] ; JSONString ]`? If not, what is the general rule for how the brackets behave in your final output? What happens with nested ones (array in array)? – Scott Sauyet May 21 '20 at 17:45

3 Answers3

2

You can use a generic traverse function -

function* traverse (value = {}, path = [])
{  if (Array.isArray(value))
     for (const [k, v] of value.entries())
       yield* traverse(v, [...path, k])
   else if (Object(value) === value)
     for (const [k, v] of Object.entries(value))
       yield* traverse(v, [...path, k])
   else
      yield { path, value }
}

And then you can use for..of to iterate through the results in a linear way -

const pathToString = (keys = []) =>
  keys.map(k => Number(k) === k ? `[${k}]` : k).join(".")

for (const { path, value } of traverse(obj))
  console.log([ pathToString(path), value, "JSONString" ])

Expand the snippet below to verify the results in your own browser -

function* traverse (value = {}, path = [])
{  if (Array.isArray(value))
     for (const [k, v] of value.entries())
       yield* traverse(v, [...path, k])
   else if (Object(value) === value)
     for (const [k, v] of Object.entries(value))
       yield* traverse(v, [...path, k])
   else
      yield { path, value }
}

const pathToString = (keys = []) =>
  keys.map(k => Number(k) === k ? `[${k}]` : k).join(".")

const json = '{"id":"0001","type":"donut","name":"Cake","image":[{"url":"images/0001.jpg","width":200,"height":200},{"url":"images/0002.jpg","width":300,"height":300}],"thumbnail":{"url":"images/thumbnails/0001.jpg","width":32,"height":32}}';

const obj = JSON.parse(json);

for (const { path, value } of traverse(obj))
  console.log([ pathToString(path), value, "JSONString" ])

Output -

[ "id", "0001", "JSONString" ]
[ "type", "donut", "JSONString" ]
[ "name", "Cake", "JSONString" ]
[ "image.[0].url", "images/0001.jpg", "JSONString" ]
[ "image.[0].width", 200, "JSONString" ]
[ "image.[0].height", 200, "JSONString" ]
[ "image.[1].url", "images/0002.jpg", "JSONString" ]
[ "image.[1].width", 300, "JSONString" ]
[ "image.[1].height", 300, "JSONString" ]
[ "thumbnail.url", "images/thumbnails/0001.jpg", "JSONString" ]
[ "thumbnail.width", 32, "JSONString" ]
[ "thumbnail.height", 32, "JSONString" ]

update

Above, the program gets close to the desired output, but we must go a little further to meet the precise requirement in the question -

const pathToString = ([ init, ...keys ]) =>
  keys.reduce
    ( (whole = "", part) =>
        Number(part) === part
          ? `${whole}[${part}]` // <-- number
          : `${whole}.${part}`  // <-- string
    , init
    )

const template = (s = "") =>
 `[ "${s}" ; \$${s} ; JSONString ]`

for (const { path, value:_ } of traverse(obj))
  console.log(template(pathToString(path)))

function* traverse (value = {}, path = [])
{  if (Array.isArray(value))
     for (const [k, v] of value.entries())
       yield* traverse(v, [...path, k])
   else if (Object(value) === value)
     for (const [k, v] of Object.entries(value))
       yield* traverse(v, [...path, k])
   else
      yield { path, value }
}

const pathToString = ([ init, ...keys ]) =>
  keys.reduce
    ( (whole = "", part) =>
        Number(part) === part
          ? `${whole}[${part}]`
          : `${whole}.${part}`
    , init
    )
    
const template = (s = "") =>
  `[ "${s}" ; \$${s} ; JSONString ]`

const json = '{"id":"0001","type":"donut","name":"Cake","image":[{"url":"images/0001.jpg","width":200,"height":200},{"url":"images/0002.jpg","width":300,"height":300}],"thumbnail":{"url":"images/thumbnails/0001.jpg","width":32,"height":32}}';

const obj = JSON.parse(json);

for (const { path, value:_ } of traverse(obj))
  console.log(template(pathToString(path)))
[ "id" ; $id ; JSONString ]
[ "type" ; $type ; JSONString ]
[ "name" ; $name ; JSONString ]
[ "image[0].url" ; $image[0].url ; JSONString ]
[ "image[0].width" ; $image[0].width ; JSONString ]
[ "image[0].height" ; $image[0].height ; JSONString ]
[ "image[1].url" ; $image[1].url ; JSONString ]
[ "image[1].width" ; $image[1].width ; JSONString ]
[ "image[1].height" ; $image[1].height ; JSONString ]
[ "thumbnail.url" ; $thumbnail.url ; JSONString ]
[ "thumbnail.width" ; $thumbnail.width ; JSONString ]
[ "thumbnail.height" ; $thumbnail.height ; JSONString ]

Now if you want all the [ ... ] results joined by ; in a single string, we can collect them all using Array.from -

const result =
  Array.from
    ( traverse(obj)
    , ({ path, value:_ }) => template(pathToString(path))
    )
    .join(";")

console.log(result)
// [ "id" ; $id ; JSONString ];[ "type" ; $type ; JSONString ];[ "name" ; $name ; JSONString ];[ "image[0].url" ; $image[0].url ; JSONString ];[ "image[0].width" ; $image[0].width ; JSONString ];[ "image[0].height" ; $image[0].height ; JSONString ];[ "image[1].url" ; $image[1].url ; JSONString ];[ "image[1].width" ; $image[1].width ; JSONString ];[ "image[1].height" ; $image[1].height ; JSONString ];[ "thumbnail.url" ; $thumbnail.url ; JSONString ];[ "thumbnail.width" ; $thumbnail.width ; JSONString ];[ "thumbnail.height" ; $thumbnail.height ; JSONString ]
Mulan
  • 129,518
  • 31
  • 228
  • 259
1

The problem is with the line:

fmMap = result.map(a => a.join('.'));

Not everything is an array, namely: id, type, name

So you need to check whether it's an array before trying to .join it.

Just change the mapping function.

fmMap = result.map(a => Array.isArray(a) ? a.join('.') : a);

If you want to have brackets for numbers only, you can remap the input, check the value against a digit regex, then output as needed.

New line

const array_mapper = (v, i, arr) => {
  if (i > 0) {
    return v.match(/^\d+$/)
      ? "[" + v + "]"
      : "." + v;
  }
  return v
}

var json = '{"id":"0001","type":"donut","name":"Cake","image":[{"url":"images/0001.jpg","width":200,"height":200},{"url":"images/0002.jpg","width":300,"height":300}],"thumbnail":{"url":"images/thumbnails/0001.jpg","width":32,"height":32}}';
var obj = JSON.parse(json);


// Recursion through the json 
function getKeys(object) {
  return Object
    .entries(object)
    .reduce((r, [k, v]) =>
      r.concat(v && typeof v === 'object' ?
        getKeys(v).map(sub => [k].concat(sub)) :
        k
      ), []
    );
}

function buildFM(object) {

  var objLength = object.length;
  var i = 1;
  var str = '';
  for (x of object) {
    var nodes = x.split(/\.(?=[^\.]+$)/);
    if (i == objLength) {
      str += '[ "' + x + '" ; $' + nodes[nodes.length - 1] + ' ; JSONString ]';
    } else {
      str += '[ "' + x + '" ; $' + nodes[nodes.length - 1] + ' ; JSONString ] ; ';
    }
    i++;
  }
  return str;
}

// Result from Recursion  
result = getKeys(obj);
console.log(result);


// Setup Map of JSON for creating FM function
const array_mapper = (v, i, arr) => {
  if (i > 0) {
    return v.match(/^\d+$/)
      ? "[" + v + "]"
      : "." + v;
  }
  return v
}

var fmMap;
fmMap = result.map(a => 
  Array.isArray(a) 
    ? a.map(array_mapper).join("") 
    : a);
console.log(fmMap);

//  Build FM Elements
var fmFX = '';
var fmFX = buildFM(fmMap);
console.log(fmFX);
A. L
  • 11,695
  • 23
  • 85
  • 163
  • Thnak you. This is so close. For the index number however it would need to be image[0].url instead of image.0.url. If not possible in this function I think I could create a function to do so. The format is very specific in order to work. – jimrice May 21 '20 at 03:41
  • @jimrice updated. Easiest way I can think of is to remap the array by adding what you need, then joining with a blank string. – A. L May 21 '20 at 05:05
  • I would also recommend using Thank you's traverse function, as it seems like your getKeys is very slow – A. L May 21 '20 at 23:42
0

It's quite unclear to me what we want in our final format. What is described in the sample output seems slightly altered from what seems likely or logical, and what is done in the code matches neither of these. Here we write some code that allows us to plug the questionable piece into some slightly generic code. It's really not reusable code, though, so it would likely be best to inline it when the correct solution is clear.

We start with a getPaths function that lists all the paths to leaves in an object, thus returning such things as ['id'], ['type'], or ['image', 1, 'url']. This could easily be modified as @Thankyou did in another answer to include both the path and the value at that path, simply by replacing yield p with yield {p, v: o}. But it's not likely necessary here unless our fourth option is actually correct. Here is the code:

const getPaths = (o) => {
  function * _getPaths(o, p) {
    if (Object(o) !== o || Object .keys (o) .length == 0) yield p 
    if (Object(o) === o)
      for (let k of Object .keys (o))
        yield * _getPaths (o[k], [...p, Number.isInteger (Number (k)) ? Number (k) : k])
  }
  return [..._getPaths(o, [])]
}

The internal function in there is a generator, and we could use that instead, if we want to simply iterate along the results and not end up with an array.

Next we write code that uses getPaths to generate our output using a customizable format for the second element of each output section. (This is because the actual requirement is not clear to me. Again, we would probably inline the correct version of this.)

That looks like this:

const format = (transformer) => (input) => 
  getPaths(input)
    .map(path => `[${[
      `"${combinePath (path)}"`,
      transformer (path, input),
      'JSONString'
    ].join(', ')}]`).join(';')

Now we can try different strategies:

With

const t1 = (path, obj) =>
  `$${combinePath(path)}`

format (t1) (input) yields entries that look like this: ["image[1].width", $image[1].width, JSONString].

But the requested output had the brackets at the end. I don't know if a clarification on that is forthcoming, but it's not too terribly difficult to make this change. With this:

const t2 = (path, obj) =>
  `"$${
    path.filter(n => Number(n) !== n).join('.')
    + path.filter(n => Number(n) === n).map(n => `[${n}]`).join('')
  }"`

format (t2) (input) yields entries that look like this: ["image[1].width", $image.width[1], JSONString]. (Note the changed position of the brackets in the second section.)

That strikes me as a strange format, especially if there are nested arrays, but if that's what's really wanted, it's available. Now the sample code from the OP did something different still, so if we only want the last node, we could write:

const t3 = (path, obj) => 
  `$${path.slice(-1)[0]}`

and then format (t3) (input) yields results like ["image[1].width", $width, JSONString]. Again, I'm not sure just how useful this is, but the requirements are unclear.

Finally, if we wanted the values of the original object in that second place, we could use a helper like the following getPath (which I usually just call path, but that seemed to be overused here) to write a version like this:

const getPath = (ps) => (o) =>
  ps.reduce ((o, p) => o[p] || {}, o)
const t4 = (path, obj) =>
  `"${getPath (path) (obj)}"`

and format (t4) (input) yields results like ["image[1].width", "300", JSONString]. We could do this more simply by modifying getPaths as described above to include the value in the result; then we could skip the helper. That would also simplify the following somewhat unrelated, but clearly useful, function:

const flattenObj = (obj) =>
  Object .fromEntries (getPaths (obj) .map (p => [combinePath (p), getPath (p) (obj)]))

which, using these same tools, transforms our object into a flattened form like this:

{
  "id": "0001",
  "type": "donut",
  "name": "Cake",
  "image[0].url": "images/0001.jpg",
  "image[0].width": 200,
  "image[0].height": 200,
  "image[1].url": "images/0002.jpg",
  "image[1].width": 300,
  "image[1].height": 300,
  "thumbnail.url": "images/thumbnails/0001.jpg",
  "thumbnail.width": 32,
  "thumbnail.height": 32
}

It's not clear to me which of these actually meets your needs, if any. But once chosen, it could be fit back into the format function by simply removing the transformer argument and putting the function body in place of the transformer(path, obj) call.

You can see all these options running in this snippet:

const getPaths = (o) => {
  function * _getPaths(o, p) {
    if (Object(o) !== o || Object .keys (o) .length == 0) yield p 
    if (Object(o) === o)
      for (let k of Object .keys (o))
        yield * _getPaths (o[k], [...p, Number.isInteger (Number (k)) ? Number (k) : k])
  }
  return [..._getPaths(o, [])]
}

const combinePath = (path) => 
  path.reduce((r, n) => r + (Number(n) == (n) ? `[${Number(n)}]` : `.${n}`))

const format = (transformer) => (input) => 
  getPaths(input)
    .map(path => `[${[
      `"${combinePath (path)}"`,
      transformer (path, input),
      'JSONString'
    ].join(', ')}]`).join(';')


const t1 = (path, obj) =>
  `$${combinePath(path)}`

const t2 = (path, obj) =>
  `"$${
    path.filter(n => Number(n) !== n).join('.')
    + path.filter(n => Number(n) === n).map(n => `[${n}]`).join('')
  }"`

const t3 = (path, obj) => 
  `$${path.slice(-1)[0]}`

const getPath = (ps) => (o) =>
  ps.reduce ((o, p) => o[p] || {}, o)
const t4 = (path, obj) =>
  `"${getPath (path) (obj)}"`

const input = {"id": "0001", "type": "donut", "name": "Cake", "image": [{"url": "images/0001.jpg", "width": 200, "height": 200}, {"url": "images/0002.jpg", "width": 300, "height": 300}], "thumbnail": {"url": "images/thumbnails/0001.jpg", "width": 32, "height": 32}};

[t1, t2, t3, t4] .forEach ((t) => {
  console .log (format (t) (input))
  console .log ('---------------------------------')
})

const flattenObj = (obj) =>
  Object .fromEntries (getPaths (obj) .map (p => [combinePath (p), getPath (p) (obj)]))
 
console .log (flattenObj (input))
.as-console-wrapper {min-height: 100% !important; top: 0}
Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103