How to unflatten a JavaScript object in a daisy-chain/dot notation into an object with nested objects and arrays?
Asked Answered
C

8

16

I want to unflatten an object like this...

var obj2 = {
    "firstName": "John",
    "lastName": "Green",
    "car.make": "Honda",
    "car.model": "Civic",
    "car.revisions.0.miles": 10150,
    "car.revisions.0.code": "REV01",
    "car.revisions.0.changes": "",
    "car.revisions.1.miles": 20021,
    "car.revisions.1.code": "REV02",
    "car.revisions.1.changes.0.type": "asthetic",
    "car.revisions.1.changes.0.desc": "Left tire cap",
    "car.revisions.1.changes.1.type": "mechanic",
    "car.revisions.1.changes.1.desc": "Engine pressure regulator",
    "visits.0.date": "2015-01-01",
    "visits.0.dealer": "DEAL-001",
    "visits.1.date": "2015-03-01",
    "visits.1.dealer": "DEAL-002"
};

... into an object with nested objects and arrays like the following:

{
  firstName: 'John',
  lastName: 'Green',
  car: {
    make: 'Honda',
    model: 'Civic',
    revisions: [
      { miles: 10150, code: 'REV01', changes: ''},
      { miles: 20021, code: 'REV02', changes: [
        { type: 'asthetic', desc: 'Left tire cap' },
        { type: 'mechanic', desc: 'Engine pressure regulator' }
      ] }
    ]
  },
  visits: [
    { date: '2015-01-01', dealer: 'DEAL-001' },
    { date: '2015-03-01', dealer: 'DEAL-002' }
  ]
}

Here's my (failed) attempt:

function unflatten(obj) {
    var result = {};

    for (var property in obj) {
        if (property.indexOf('.') > -1) {
            var substrings = property.split('.');

            console.log(substrings[0], substrings[1]);


        } else {
            result[property] = obj[property];
        }
    }

    return result;
};

I quickly started repeating code unnecessarily in order to do the nesting of objects and arrays. This is definitely something that needs recursion. Any ideas?

EDIT: I've also asked the opposite, flatten, in another question.

Certie answered 9/3, 2017 at 12:6 Comment(2)
I have a strong deja vu here. I could swear I've read this question yesterday. -- Edit: alright, it's the opposite of yesterday's question.Issuance
The previous question was about flatten, this one is about unflatten. Feels like some home tasksPilar
L
27

You can first use for...in loop to loop object properties, then split each key at . then use reduce to build nested properties.

var obj2 = {"firstName":"John","lastName":"Green","car.make":"Honda","car.model":"Civic","car.revisions.0.miles":10150,"car.revisions.0.code":"REV01","car.revisions.0.changes":"","car.revisions.1.miles":20021,"car.revisions.1.code":"REV02","car.revisions.1.changes.0.type":"asthetic","car.revisions.1.changes.0.desc":"Left tire cap","car.revisions.1.changes.1.type":"mechanic","car.revisions.1.changes.1.desc":"Engine pressure regulator","visits.0.date":"2015-01-01","visits.0.dealer":"DEAL-001","visits.1.date":"2015-03-01","visits.1.dealer":"DEAL-002"}

function unflatten(data) {
  var result = {}
  for (var i in data) {
    var keys = i.split('.')
    keys.reduce(function(r, e, j) {
      return r[e] || (r[e] = isNaN(Number(keys[j + 1])) ? (keys.length - 1 == j ? data[i] : {}) : [])
    }, result)
  }
  return result
}

console.log(unflatten(obj2))
Landau answered 9/3, 2017 at 13:3 Comment(3)
Line 8 of this script is gnarly, it's tough to make out what's happening. What does the r[e] = isNan(... do? Front of a ternary is boolean, does an assignment return something?Virginia
@Virginia isNaN(Number(keys[j + 1])) checks if the next key is a number or not when converted from a string to a number. If it returns true that means its not a number and then you evaluate another condition to see if its last key or not and assign either value or an empty object. But if it returns false then it is a number and then it assigns an array.Landau
So if you have key like this car.revisions.0.miles first two parts (car and revisions) are not numbers, third part is a number and last part is not a number.Landau
K
6

Try breaking the problem down into two distinct challenges:

  1. Setting a value by path
  2. Looping over an object and unflattening the keys one by one

You might start with a setIn function that would look something like this:

function setIn(path, object, value) {
  let [key, ...keys] = path; 

  if (keys.length === 0) {
    object[key] = value;
  } else {
    let nextKey = keys[0];
    object[key] = object[key] || isNaN(nextKey) ? {} : [];
    setIn(keys, object[key], value);
  }

  return object;
}

Then combine it with an unflatten function which loops over an object running setIn for each key.

function unflatten(flattened) {
  let object = {};

  for (let key in flattened) {
    let path = key.split('.');
    setIn(path, object, flattened[key]);
  }

  return object;
}

Of course, there's already an npm package for doing this, and it'd also it'd be easy to implement your own using functions like _.set from lodash.

It's unlikely that you'd ever run into a long enough path that you'd end up running out of stack frames, but of course it's possible to implement setIn without recursion, using loops or trampolines.

And finally, if immutable data is your thing and you want to work with a version of setIn that doesn't modify your data structures, then you could take a look at the implementation in Zaphod—a JavaScript library for treating the native data structures as though they were immutable.

Kuvasz answered 9/3, 2017 at 12:23 Comment(1)
You have an error. It should be object[key] = object[key] || (isNaN(nextKey) ? {} : []);, or simply object[key] ??= isNaN(nextKey) ? {} : [];Tensive
P
6

You can use lodash library with keys and set functions to build a cleaner code.

The idea is, you can transform each entry in a object with .set and merge it in a global object.

const messages = {
        "firstName": "John",
        "lastName": "Green",
        "car.make": "Honda",
        "car.model": "Civic",
        "car.revisions.0.miles": 10150,
        "car.revisions.0.code": "REV01",
        "car.revisions.0.changes": "",
        "car.revisions.1.miles": 20021,
        "car.revisions.1.code": "REV02",
        "car.revisions.1.changes.0.type": "asthetic",
        "car.revisions.1.changes.0.desc": "Left tire cap",
        "car.revisions.1.changes.1.type": "mechanic",
        "car.revisions.1.changes.1.desc": "Engine pressure regulator",
        "visits.0.date": "2015-01-01",
        "visits.0.dealer": "DEAL-001",
        "visits.1.date": "2015-03-01",
        "visits.1.dealer": "DEAL-002"
};

const unflatten = (flattedObject) => {
    let result = {}
    _.keys(flattedObject).forEach(function (key, value){    
        _.set(result, key, flattedObject[key])        
    })
    return result
}

console.log(unflatten(messages))
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js"></script>
Pam answered 22/6, 2022 at 11:55 Comment(0)
W
1

As this old question has been recently revived, I think it might use another approach. I keep handy in my personal library a function called hydrate that takes an array of path/value pairs and turns them into a nested object. These path/value pairs are similar to key/value ones, but have an array of nodes keys instead of a single key. I usually call these pathEntries as a rough parallel to the result of Object.entries.

With this function, I can write unflatten like this:

const unflatten = (o) => hydrate(Object.entries(o).map(
  ([k, v]) => [k.split('.').map(s => s.match(/^\d+$/) ? Number(s) : s), v]
))

The only thing even slightly tricky here is the mapping of some of the nodes to integers. hydrate uses integers as array indices, distinguished from string values which are used as Object keys.

hydrate is built on setPath, another generally useful utility function which sets the value at a certain path on an object to the value supplied.

Altogether, it looks like this:

const setPath = ([p, ...ps]) => (v) => (o) =>
  p == undefined ? v : Object.assign (
    Array.isArray (o) || Number.isInteger(p) ? [] : {},
    {...o, [p]: setPath(ps)(v)((o || {})[p])}
  )

const hydrate = (xs) =>
  xs.reduce((a, [p, v]) => setPath(p)(v)(a), {})

const unflatten = (o) => hydrate(Object.entries(o).map(
  ([k, v]) => [k.split('.').map(s => s.match(/^\d+$/) ? Number(s) : s), v]
))

const obj2 = {"firstName": "John", "lastName": "Green", "car.make": "Honda", "car.model": "Civic", "car.revisions.0.miles": 10150, "car.revisions.0.code": "REV01", "car.revisions.0.changes": "", "car.revisions.1.miles": 20021, "car.revisions.1.code": "REV02", "car.revisions.1.changes.0.type": "asthetic", "car.revisions.1.changes.0.desc": "Left tire cap", "car.revisions.1.changes.1.type": "mechanic", "car.revisions.1.changes.1.desc": "Engine pressure regulator", "visits.0.date": "2015-01-01", "visits.0.dealer": "DEAL-001", "visits.1.date": "2015-03-01", "visits.1.dealer": "DEAL-002"}

console.log(unflatten (obj2))
Werbel answered 28/3 at 2:27 Comment(5)
The two checks for the number were bugging me. The .map(...) expression after k.split(".") can be removed, then swap Number.isInteger(p) for /^\d+$/.test(p). This works because Object.assign(target, ...sources) can modify an array index with a string key.Kampmeier
Today I learned Object.assign([0,1,2,3,4],"xyz") results in ["x","y","z",3,4] :DKampmeier
@Mulan: That Object.assign behavior shouldn't be surprising, but it sure is!Werbel
@Mulan: Although that change works for this problem, it is not appropriate for the general setPath, which by design gives different results when an integer is supplied versus its stringified version . That is, setPath(['a', 0, 'b'])('foo')({}) yields {a: [{b: 'foo'}]} whereas the similar setPath(['a', '0', 'b'])('foo')({}) yields {a: {'0': {b: 'foo}}}. Granted, that latter construct is unusual, but setPath is designed to allow it.Werbel
ah, but of course. thanks for the illumination.Kampmeier
U
0

You could walk the splitted substrings and a temporary object with a check if the key exists and build a new property with a check for the next property if it is a finite number, then assign an array, otherwise an object. At last assign with the last substring the value to the temp object.

function unflatten(obj) {
    var result = {}, temp, substrings, property, i;
    for (property in obj) {
        substrings = property.split('.');
        temp = result;
        for (i = 0; i < substrings.length - 1; i++) {
            if (!(substrings[i] in temp)) {
                if (isFinite(substrings[i + 1])) { // check if the next key is
                    temp[substrings[i]] = [];      // an index of an array
                } else {
                    temp[substrings[i]] = {};      // or a key of an object
                }
            }
            temp = temp[substrings[i]];
        }
        temp[substrings[substrings.length - 1]] = obj[property];
    }
    return result;
};

var obj2 = { "firstName": "John", "lastName": "Green", "car.make": "Honda", "car.model": "Civic", "car.revisions.0.miles": 10150, "car.revisions.0.code": "REV01", "car.revisions.0.changes": "", "car.revisions.1.miles": 20021, "car.revisions.1.code": "REV02", "car.revisions.1.changes.0.type": "asthetic", "car.revisions.1.changes.0.desc": "Left tire cap", "car.revisions.1.changes.1.type": "mechanic", "car.revisions.1.changes.1.desc": "Engine pressure regulator", "visits.0.date": "2015-01-01", "visits.0.dealer": "DEAL-001", "visits.1.date": "2015-03-01", "visits.1.dealer": "DEAL-002" };

console.log(unflatten(obj2));
.as-console-wrapper { max-height: 100% !important; top: 0; }

A slightly more compact version could be this

function unflatten(object) {
    var result = {};
    Object.keys(object).forEach(function (k) {
        setValue(result, k, object[k]);
    });
    return result;
}

function setValue(object, path, value) {
    var way = path.split('.'),
        last = way.pop();

    way.reduce(function (o, k, i, kk) {
        return o[k] = o[k] || (isFinite(i + 1 in kk ? kk[i + 1] : last) ? [] : {});
    }, object)[last] = value;
}

var obj2 = { "firstName": "John", "lastName": "Green", "car.make": "Honda", "car.model": "Civic", "car.revisions.0.miles": 10150, "car.revisions.0.code": "REV01", "car.revisions.0.changes": "", "car.revisions.1.miles": 20021, "car.revisions.1.code": "REV02", "car.revisions.1.changes.0.type": "asthetic", "car.revisions.1.changes.0.desc": "Left tire cap", "car.revisions.1.changes.1.type": "mechanic", "car.revisions.1.changes.1.desc": "Engine pressure regulator", "visits.0.date": "2015-01-01", "visits.0.dealer": "DEAL-001", "visits.1.date": "2015-03-01", "visits.1.dealer": "DEAL-002" };

console.log(unflatten(obj2));
.as-console-wrapper { max-height: 100% !important; top: 0; }
Unusual answered 9/3, 2017 at 12:23 Comment(0)
U
0

const data = {
  firstName: 'John',
  lastName: 'Green',
  'car.make': 'Honda',
  'car.model': 'Civic',
  'car.revisions.0.miles': 10150,
  'car.revisions.0.code': 'REV01',
  'car.revisions.0.changes': '',
  'car.revisions.1.miles': 20021,
  'car.revisions.1.code': 'REV02',
  'car.revisions.1.changes.0.type': 'asthetic',
  'car.revisions.1.changes.0.desc': 'Left tire cap',
  'car.revisions.1.changes.1.type': 'mechanic',
  'car.revisions.1.changes.1.desc': 'Engine pressure regulator',
  'visits.0.date': '2015-01-01',
  'visits.0.dealer': 'DEAL-001',
  'visits.1.date': '2015-03-01',
  'visits.1.dealer': 'DEAL-002',
};

const objKeys = Object.keys(data);

function assign(obj, keys, val) {
  const lastKey = keys.pop();
  const lastObj = keys.reduce((obj, key) => (obj[key] = obj[key] || {}), obj);
  lastObj[lastKey] = val;
}

const final = objKeys.reduce((acc, cv) => {
  if (!cv.includes('.')) {
    acc[cv] = data[cv];
  }

  const keys = cv.split('.');
  assign(acc, keys, data[cv]);

  return acc;
}, {});

console.log({ final });
Underclothing answered 20/9, 2022 at 5:18 Comment(0)
M
0
type tsAppendableObj = { [key: string]: any }
const test = {
    r_0_0_1: {
        component: 'header_hosted_by',
        langs: 'header_hosted_by',
    },
    r_0_0_2: {
        component: 'header_lang',
        langs: '',
    },
    r_0_0_2_0: {
        component: 'header_lang_en',
        langs: 'header_lang_en',
    },
    r_0_0_2_1: {
        component: 'header_lang_fr',
        langs: 'header_lang_fr',
    },
    r_0: {
        component: 'title',
        langs: 'info_page_title',
    },
    r_0_0: {
        component: 'header',
        langs: 'api_version',
    },
    r_0_0_0: {
        component: 'header_date',
        langs: 'header_date',
    },
}

function unflatten2(data: tsAppendableObj, separator: string) {
    let result = <tsAppendableObj>{}
    let temp = <tsAppendableObj>{}
    let temp2 = <tsAppendableObj>{}
    let levels = <tsAppendableObj>{}
    let property
    let i

    for (property in data) {
        levels = property.split(separator)
        let gg = levels[0]
        temp = result
        for (i = 1; i < levels.length; i++) {
            gg = gg + separator + levels[i]
            if (!temp[gg]) {
                temp[gg] = {
                    main: {},
                    items: {},
                }
            }
            if (i === levels.length - 1) temp[gg].main = data[property]
            temp = temp[gg].items
        }
    }
    return result
}

console.log(unflatten2(test, '_'))
Merlynmermaid answered 3/4, 2023 at 18:16 Comment(1)
if works in unordered lists, and it gives a more friendly main/items structure with full keys so you can more or less recursively iterate trough it... can be improved ...Merlynmermaid
B
0
const unflatten = (keys: string[], value: unknown): Record<string, unknown> => {
  return { [keys[0]]: keys.length > 1 ? unflatten(keys.slice(1), value): value };
};

unflatten('test.foo.bar'.split('.'), 'my text')
Bendite answered 27/3 at 18:16 Comment(1)
Welcome to StackOverflow! This looks to be only a partial answer to the question. It correctly builds an object with one key, but it doesn't build up an object from all the key/value pairs in the input.Werbel

© 2022 - 2024 — McMap. All rights reserved.