Home > Software design >  Convert array of nested Javascript objects into 2D array with unique keys-subkeys
Convert array of nested Javascript objects into 2D array with unique keys-subkeys

Time:01-26

I want to convert an array of nested objects into a 2d array that I want to use as part of an apps script batchUpdate in Google Sheets.

I am using an object array as input, in apps script as below:

    [{a:"val1", b:{x:"val2",y:"val3",z:"val4"}, c:{s:"val5",t:"val6",r:"val7"},
    {a:"val8", b:{x:"val9",z:"val10"},c:{t:"val11",r:"val12"},
    {a:"val13",b:{y:"val14"}, c:{s:"val15",t:"val16",z:"val17"}]

so not all nested objects have all keys.

Since the number of objects is quite big, I am looking for an efficient way to process and create a 2D array where the first column holds the unique keys, un-nested, and the sub-keys grouped together, while each further column holds the values of each object (where there is a key-value pair). Like the table below:

Key_subkey Object 1 values Object 2 values Object 3 values
a val1 val8 val13
b_x val2 val9
b_y val3 val14
b_z val4 val10
c_s val5 val15
c_t val6 val11 val16
c_r val7 val12
c_z val17

I am seeking to avoid nested for-loops to populate the array as it would be inefficient.

Any ideas on how to do that fast and concisely?

TIA

CodePudding user response:

If I understand correctly, you have an array of objects.. each object is a column and its properties are expected to have an expanded id (when flattened) as the composition of parent ids till root element.

So that the resulting object is a mapping of full expanded ids and their list of values as an array with slots position mapped to the corresponding column.

It's easier to show than to explain...

so here's there's the fillBag function that will iterate over the passed object properties (recursively) while filling the passed bag object that will hold the final result:

//this function is fully commented in the live snippet
function fillBag(prefix, colIndex, obj, bag){ 
    Object.entries(obj).forEach(
    ([key, value])=>{              
        const index = `${prefix}${key}`;              
        if(typeof(value) === 'string' || value instanceof String){                
            const values = (index in bag) ? bag[index] : bag[index] = [];
            values.length = colIndex;
            values.push(value);                                
        }else{        
            fillBag(index, colIndex, value, bag);
        }
    });
}

I used your same input array but I had to make some corrections because you had some syntax errors missing closing }.

This is the final object returned:

{
  "a": [ "val1", "val8", "val13" ],
  "bx": [ "val2", "val9", undefined],
  "by": [ "val3", undefined, "val14" ],
  "bz": [ "val4", "val10", undefined ]
  "cs": [ "val5", undefined, "val15" ],
  "ct": [ "val6", "val11", "val16" ],
  "cr": [ "val7", "val12", undefined ],
  "cz": [ undefined, undefined, "val17" ]
}

if you needed an array of arrays instead of an object mapping unique keys to array values, you can also use Object.entries on top of that:

Object.entries($bag) //...[ key, [values] ]

Warning it now takes into account the fact that later columns may stack up and if a given index doesn't show up anymore, the array of values for that index wwould not contain undefined values for the indexes following the last column where the property name did its last appearance. To correct that behaviour, the resulting bag get visited one last time after the whole object was processed, and ALL the arrays resized with the size of the biggest one (number of total columns).

function fillTrailingEmptyColumns(obj, cols){
  Object.values(obj).forEach( values =>{
    values.length = cols;
  });  
}

Sparse arrays and setting .length property

It's worth to say that since the values are stored in an array for each key, the array will contain undefined values for columns in which the given index didn't have any value.

It could be interesting to further read about sparse arrays and empty slot.

Most people will find very surprising that you can actually set the Array.length property

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Indexed_collections#sparse_arrays

const data = [
  {
    a:"val1",
    b: {
      x:"val2",
      y:"val3",
      z:"val4"
    },
    c: {
      s:"val5",
      t:"val6",
      r:"val7"
    }
  },
  {
    a:"val8",
    b:{
      x:"val9",
      z:"val10"
    },
    c:{
      t:"val11",
      r:"val12"
    }
  },
  {    
    a:"val13",
    b:{
      y:"val14"
    },
    c:{
      s:"val15",
      t:"val16",
      z:"val17"
    }
  }
]

const bag = {};

//fills the bag with key->values
data.forEach( (obj, i) => fillBag('', i, obj, bag));

//this is an information I could grab from data.length as well
const cols = findMaxColumns(bag);

//fills the trailing missing columns in bag for each key
fillTrailingEmptyColumns(bag, cols);

console.log(bag);

//returns the maximum number of columns descrived in the obj
function findMaxColumns(obj){
  return Object.values(obj).reduce(
    (max, value)=>{
      if(value.length > max)
        return value.length;
      else
        return max;        
    },
    0
  );
}

function fillTrailingEmptyColumns(obj, cols){
  Object.values(obj).forEach( values =>{
    values.length = cols;
  });  
}

//fills the bag object with values coming from obj at the given prefix and colIndex
function fillBag(prefix, colIndex, obj, bag){

  //for each entry in obj (an entry is key-value property pair)
  Object.entries(obj).forEach(
    ([key, value])=>{      
      //current index based on prefix
      const index = `${prefix}${key}`;
      
      //if the current property value is a string
      if(typeof(value) === 'string' || value instanceof String){        
        //if composed key exists in bag
        if(index in bag){          
          let values = bag[index];      
          //change the size of values in the array based on the current column
          values.length = colIndex;
          //push the current value in the array of values for this key
          values.push(value);
        }
        //else if composed key doesn't exist yet in bag
        else{
          //create a new array filled with nulls until now          
          let values = [];
          //change the size of values in the array based on the current column
          values.length = colIndex;
          //push the current value in the array of values for this key
          values.push(value);
          //sets the key in the bag for its first time
          bag[index] = values;
        }
      }
      //else if the obj is not a string (thus a nested properties bag)
      else{
        //recursively call fillBag with the current composed index and passing the current value
        fillBag(index, colIndex, value, bag);
      }
    });
    
}

  • Related