Search code examples
angulartypescriptobjectnestednested-loops

Typescript Flat Object to Nested Tree Object, How to build it with distinct values?


This is the flat object I'm working with, it has many more results, ~6800. I've been trying to convert it to a nested tree (like the one listed below) for about 13 hours now & I'm truly lost.

[
  {
    "make": "Acura",
    "classification": "Mid SUV",
    "segment": "Competitive Trucks",
    "model": "RDX",
    "catalogDetail": "RDX_SUV_4_Gasoline_2013_Base w/Tech_FWD_3.5_6_105.7_Automatic"
  },
  {
    "make": "Acura",
    "classification": "Midsize Car",
    "segment": "Competitive Cars",
    "model": "TSX",
    "catalogDetail": "TSX_Sedan_4_Gasoline_2012_Base w/Tech_FWD_2.4_4_106.4_Automatic"
  },
  {
    "make": "Aston Martin",
    "classification": "Compact Car",
    "segment": "Competitive Cars",
    "model": "DB11",
    "catalogDetail": "DB11_Convertible_2_Gasoline_2019_Volante_RWD_4.0_8_110.4_Automatic"
  }
]

What I'm trying to do is build this flat object into a nested structure like this:

[
  {
    "make": [ 
      { "Acura",
        "classification": [{
          "Mid SUV",
          "segment": [{
            "Competitive Trucks",
            "model": [{
              "RDX",
              "catalogDetail": [{
                "RDX_SUV_4_Gasoline_2013_Base w/Tech_FWD_3.5_6_105.7_Automatic"
              }]
            }]
          }],
          "Midsize Car",      
          "segment": [{
            "Competitive Cars",
            "model": [{
              "TSX",
              "catalogDetail": [{
                "TSX_Sedan_4_Gasoline_2012_Base w/Tech_FWD_2.4_4_106.4_Automatic"
              }]
            }]
          }] 
        }],
      }
    ]
  },
  {
    "make": [
      { "Aston Martin",
        "classification": [{
          "Compact Car",
          "segment": [{
            "Competitive Cars",
            "model": [{
              "DB11",
              "catalogDetail": [{
                "DB11_Convertible_2_Gasoline_2019_Volante_RWD_4.0_8_110.4_Automatic"
              }]
            }]
          }]
        }]
      }
    ]
  }
]

Where the structure falls into a nested structure like: make --> classification --> segment --> model --> catalogdetail. So there would be multiple car makes, ford, Cadillac, etc. Multiple classifications, multiple different segments under each make.

This is what I've tried:

    this._servicesService.getHierarchy().subscribe(data => {
      console.log(data)
/*      this.data = data;*/
      /*      this.dataStore = data;*/


      let distinctSeg = [...new Set(data.map(x => x.segment))];
      let distinctClass = [...new Set(data.map(x => x.classification))];
      let distinctMod = [...new Set(data.map(x => x.model))];
      let distinctCd = [...new Set(data.map(x => x.catalogDetail))];

     const newData = [];
      data.forEach(e => {
        if (newData.length == 0) {
          newData.push({
            make: e.make,
            segment: e.segment,
            classification: e.classification,
            model: [e.model],
            catalogDetail: [e.catalogDetail]
          });
        } else {
          let foundIndex = newData.findIndex(fi => fi.make === e.make, fi => fi.segment = e.segment);
          if (foundIndex >= 0) {
            /* newData[foundIndex].make.push(e.make),*/
            /* newData[foundIndex].segment.push(e.segment),*/
            /* newData[foundIndex].classification.push(e.classification),*/
            newData[foundIndex].model.push(e.model);
            newData[foundIndex].catalogDetail.push(e.catalogDetail);
          } else {
            newData.push({
              make: e.make,
              segment: distinctSeg,
              classification: distinctClass,
              model: [e.model],
              catalogDetail: [e.catalogDetail]
            });
          }
        }
      });
      console.log(newData);
    })

This give me distinct values for model, segment and class, (not model or catalogDetail for some reason) but the nested structure isn't there & i'm truly lost on how to proceed. I've looked at a number of examples on here & I really haven't been successful applying any of the previously listed routes. Any insight or tips would be greatly appreciated. I attached a picture to better visualize the final desired output in case I have the wrong syntax. tree


Solution

  • It looks like, for input-output, you want something like this:

    const inp = [
      { a: "x", b: "u", c: "q" }, { a: "x", b: "v", c: "r" },
      { a: "x", b: "u", c: "s" }, { a: "x", b: "v", c: "t" },
      { a: "y", b: "u", c: "q" }, { a: "y", b: "u", c: "r" },
      { a: "y", b: "v", c: "s" },
    ];
    
    const outp = collect(inp, "a", "b", "c");
    console.log(outp);
    // {x:{u: ["q","s"], v:["r","t"]}, y:{u:["q","r"], v:["s"]}}
    

    where collect() is a function that takes an array of objects and a list of keys of those objects. (It needs to be at least one key, and the input objects should have string values at those keys.) Our job is to implement collect().


    The approach I take will be recursive; first, the base case, where you call collect(inp, key1) for just one key. In this case, we just want to return an array of the values at the key1 key, which we can get just by mapping the input array; i.e., inp.map(v => v[key1]).

    Then the recursive step: when you call collect(inp, key1, ...keyRest) with more than one key, the output will have keys corresponding to the key1 property of the elements of inp. In the above example, if we call collect(inp, "a", ...keyRest), then the output will have keys x and y. For the x key, we collect the elements of inp whose a property is "x" into another array inpX, and then the value at the x key will be collect(inpX, ...keyRest). And analogously for the y key. That is, we split the input array into sub-arrays corresponding to the values at the key1 key, and then evaluate collect(subArr, ...keyRest) for each subarray.


    That's a verbal description of the algorithm. Let's see what it implies for the typings for collect():

    declare function collect<K extends (keyof T)[], T extends object>(
      arr: (T & Record<K[number], string>)[], ...keys: K): Collect<K>;
    
    type Collect<K extends PropertyKey[]> =
      K extends [any] ? string[] :
      K extends [any, ...infer R extends PropertyKey[]] ? { [k: string]: Collect<R> } :
      never;
    

    Here we are saying that collect() is a generic function that accepts an array of elements of object type T and a list of keys of tuple type K. We constrain arr so that each element of the arr array is of type T and has a string property at each key element in the K tuple. And we constrain K so that each key element is some key of T.

    And we return a value of type Collect<K>, where Collect<K> is itself a recursive conditional type representing an object with nested string index signatures and whose base case value type is string[].


    And now for the implementation:

    function collect(arr: any[], ...keys: string[]) {
      if (!keys.length) throw new Error("need at least one key");
      const [k, ...rest] = keys;
    
      // base case
      if (!rest.length) return arr.map(v => v[k]);
    
      // recurse; first collect the sub-arrays for each value at key k
      const subArrays: Record<string, any[]> = {}
      arr.forEach(v => (subArrays[v[k]] ??= []).push(v));
    
      // then build the return object by calling collect(subArrayVk, ...rest) for each subarray
      const ret: Record<string, any> = {};
      Object.keys(subArrays).forEach(vk => ret[vk] = collect(subArrays[vk], ...rest));
      return ret;
    }
    

    Because the function's call signature returns a generic conditional type, it's easiest to make the function a single-call-signature overload so that the implementation is checked loosely. That just means we have a declared call signature before the implementation:

    // call signature
    function collect<K extends (keyof T)[], T extends object>(
      arr: (T & Record<K[number], string>)[], ...keys: K): Collect<K>;
    
    // implementation
    function collect(arr: any[], ...keys: string[]) {
      // ✂ snip, see above
    }
    

    All right, let's test it out:

    const outp = collect(inp, "a", "b", "c");
    console.log(outp);
    // {x:{u: ["q","s"], v:["r","t"]}, y:{u:["q","r"], v:["s"]}}
    

    That works! And your example:

    const x = collect(arr, "make", "classification", "segment", "model", "catalogDetail");
    console.log(x);
    /* {
      "Acura": {
        "Mid SUV": {
          "Competitive Trucks": {
            "RDX": [
              "RDX_SUV_4_Gasoline_2013_Base w/Tech_FWD_3.5_6_105.7_Automatic"
            ]
          }
        },
        "Midsize Car": {
          "Competitive Cars": {
            "TSX": [
              "TSX_Sedan_4_Gasoline_2012_Base w/Tech_FWD_2.4_4_106.4_Automatic"
            ]
          }
        }
      },
      "Aston Martin": {
        "Compact Car": {
          "Competitive Cars": {
            "DB11": [
              "DB11_Convertible_2_Gasoline_2019_Volante_RWD_4.0_8_110.4_Automatic"
            ]
          }
        }
      }
    }   */
    

    That also works!

    Playground link to code