Home > Back-end >  Lodash merging and unioning of nested array / object structure
Lodash merging and unioning of nested array / object structure

Time:08-04

I have two arrays that need merging in Javascript. They are arranged as follows:

arrayA = [town1A, town2A, town3A];
arrayB = [town3B, town5B];

Each town is an object with a townName: 'town1' (matching the object variable name). Each town also has an array of occupants: [{}, {}] which each have their own personName, and a status: 'dead' or 'alive'.

My goal, is that after merging, the new array will contain every unique town according to townName (town3B and town3A both have townName : 'town3').

arrayC = [town1, town2, town3, town5]

Any new towns in arrayB (i.e., town5) should be added directly to the list. Any towns with the same name (i.e., town3) should combine their lists of occupants, but remove any "dead" people. ArrayB has priority over ArrayA when determining status, as it is "overwriting" the old data. For example:

arrayA.town3.occupants = [{name: 'Bob', status: 'alive'}, {name: 'Joe', status: 'alive'}];
arrayB.town3.occupants = [{name: 'Bob', status: 'dead'}, {name: 'Alice', status: 'alive'}];

arrayC.town3.occupants = [{name: 'Joe', status: 'alive'}, {name: 'Alice', status: 'alive'}];

I'm just struggling with the logic sequence process here and need a nudge to figure out what tools to use. Currently I'm trying to work with Lodash's _.merge and _.union in some combination. It seems I can use _.mergeWith or _.unionBy to "nest" the merging steps without resorting to manually looping over the arrays, but their usage is going over my head. If a solution exists that uses one of those, I would like to see an example to learn better how they work.

Edit: I was asked for the entire contents of an example arrayA and arrayB:

arrayA = [
    {
        townName: 'town1',
        occupants: [
            {name: 'Charlie', status: 'alive'},
            {name: 'Jim', status: 'dead'}
        ]
    },
    {
        townName: 'town2',
        occupants: [
            {name: 'Rachel', status: 'alive'},
        ]
    },
    {
        townName: 'town3',
        occupants: [
            {name: 'Bob', status: 'alive'},
            {name: 'Joe', status: 'alive'}
        ]
    }
];

arrayB = [
    {
        townName: 'town3',
        occupants: [
            {name: 'Bob', status: 'dead'},
            {name: 'Alice', status: 'alive'}
        ]
    },
    {
        townName: 'town5',
        occupants: [
            {name: 'Sam', status: 'dead'},
            {name: 'Ray', status: 'alive'},
            {name: 'Bob', status: 'alive'},
        ]
    }
];

The output I expect is:

arrayC = [
    {
        townName: 'town1',
        occupants: [
            {name: 'Charlie', status: 'alive'},
        ]
    },
    {
        townName: 'town2',
        occupants: [
            {name: 'Rachel', status: 'alive'},
        ]
    },
    {
        townName: 'town3',
        occupants: [
            {name: 'Joe', status: 'alive'},
            {name: 'Alice', status: 'alive'}
        ]
    },
    {
        townName: 'town5',
        occupants: [
            {name: 'Ray', status: 'alive'},
            {name: 'Bob', status: 'alive'},
        ]
    }
];

CodePudding user response:

You can define a function for merging arrays with a mapper like this:

  const union = (a1, a2, id, merge) => {
    const dict = _.fromPairs(a1.map((v, p) => [id(v), p]))
    return a2.reduce((a1, v) => {
      const i = dict[id(v)]
      if (i === undefined) return [...a1, v] 
      return Object.assign([...a1], { [i]: merge(a1[i], v) })
    }, a1)
  }

and use it like this:

union(
  arrayA, 
  arrayB, 
  town => town.townName, 
  (town1, town2) => ({
    ...town1,
    occupants: union(
      town1.occupants,
      town2.occupants,
      occupant => occupant.name,
      (occupant1, occupant2) => occupant1.status === 'alive' ? occupant1 : occupant2
    ).filter(occupant => occupant.status === 'alive')
  })
)

CodePudding user response:

The complexity with this problem is that you want to merge on 2 different layers:

  • you want to merge two arrays of towns, so you need to decide what to do with towns common to the two arrays;
  • when handling two towns with common name, you want to merge their occupants.

Now, both _.merge and _.mergeWith are good candidates to accomplish the task, except that they are for operating on objects (or associative maps, if you like), whereas you have vectors of pairs (well, not really pairs, but objects with two elements with fixed keys; name/status and townName/occupants are fundamentally key/value) at both layers mentioned above.

One function that can be useful in this case is one that turns an array of pairs into an object. Here's such a utility:

arrOfPairs2Obj = (k, v) => (arr) => _.zipObject(..._.unzip(_.map(arr, _.over([k, v])))); 

Try executing the following

townArr2townMap = arrOfPairs2Obj('townName', 'occupants');
mapA = townArr2townMap(arrayA);
mapB = townArr2townMap(arrayB);

to see what it does.

Now you can merge mapA and mapB more easily…

_.mergeWith(mapA, mapB, (a, b) => {
    // … well, not that easily
})

Again, a and b are arrays of "pairs" name/status, so we can reuse the abstraction I showed above, defining

personArr2personMap = arrOfPairs2Obj('name', 'status');

and using it on a and b.

But still, there are some problems. I thought that the (a, b) => { … } I wrote above would be called by _.mergeWith only for elements which have the same key across mapA and mapB, but that doesn't seem to be the case, as you can verify by running this line

_.mergeWith({a: 1, b: 3}, {b:2, c:4, d: 6}, (x, y) => [x, y])

which results in

{
  a: 1
  b: [3, 2]
  c: [undefined, 4]
  d: [undefined, 6]
}

revealing that the working lambda is called for the "clashing" keys (in the case above just b), and also for the keys which are absent in the first object (in the case above c and d), but not for those absent in the second object (in the case above a).

This is a bit unfortunate, because, while you could filter dead people out of towns which are only in arrayB, and you could also filter out those people which are dead in arrayB while alive in arrayA, you'd still have no place to filter dead people out of towns which are only in arrayA.

But let's see how far we can get. _.merge doc reads

Source objects are applied from left to right. Subsequent sources overwrite property assignments of previous sources.

So we can at least handle the merging of towns common across the array in a more straightforward way. Using _.merge means that if a person is common in the two arrays, we'll always pick the one from arrayB, whether that's (still) alive or (just) dead.

Indeed, a strategy like this doesn't give you the precise solution you want, but not even one too far from it,

notSoGoodResult = _.mergeWith(mapA, mapB, (a, b) => {
   return _.merge(personArr2personMap(a), personArr2personMap(b));
})

its result being the following

{
  town1: [
    {name: "Charlie", status: "alive"},
    {name: "Jim", status: "dead"}
  ],
  town2: [
    {name: "Rachel", status: "alive"}
  ],
  town3:
    Alice: "alive"
    Bob: "dead"
    Joe: "alive"
  },
  town5: {
    Bob: "alive"
    Ray: "alive"
    Sam: "dead"
  }
}

As you can see

  • Bob in town3 is correctly dead,
  • we've not forgotten Alice in town3,
  • nor have we forogtten about Joe in town3.

What is left to do is

  • "reshaping" town3 and town5 to look like town1 and town2 (or alternatively doing the opposite),
  • filtering away all dead people (there's no more people appearing with both the dead and alive status, so you don't risk zombies).

Now I don't have time to finish up this, but I guess the above should help you in the right direction.


The bottom line, however, in my opinion, is that JavaScript, even with the power of Lodash, is not exactly the best tool for functional programming. _.mergeWith disappointed me, for the reason explained above.

  • Related