2

This is a continuation of solution described in Combine json arrays by key, javascript, except that the input JSON has deeper level of nested array objects:

json1

[
  {
    "id": 1,
    "name": "aaa",
    "addresses": [
      {
        "type": "office",
        "city": "office city"
      },
      {
        "type": "home1",
        "city": "home1 city"
      }
    ]
  },
  {
    "id": 2,
    "name": "bbb",
    "addresses": [
      {
        "type": "office",
        "city": "office city"
      },
      {
        "type": "home1",
        "city": "home1 city"
      }
    ]
  }
]

json2

[
  {
    "id": 1,
    "name": "aaa1",
    "addresses": [
      {
        "type": "home1",
        "city": "home1 new city"
      },
      {
        "type": "home2",
        "city": "home2 city"
      }
    ]
  },
  {
    "id": 3,
    "name": "ccc",
    "addresses": [
      {
        "type": "home1",
        "city": "home1 city"
      },
      {
        "type": "home2",
        "city": "home2 city"
      }
    ]
  }
]

Expected result array

[
  {
    "id": 1,
    "name": "aaa1",
    "addresses": [
      {
        "type": "office",
        "city": "office city"
      },            
      {
        "type": "home1",
        "city": "home1 new city"
      },
      {
        "type": "home2",
        "city": "home2 city"
      }
    ]
  },
  {
    "id": 2,
    "name": "bbb",
    "addresses": [
      {
        "type": "office",
        "city": "office city"
      },
      {
        "type": "home1",
        "city": "home1 city"
      }
    ]
  },          
  {
    "id": 3,
    "name": "ccc",
    "addresses": [
      {
        "type": "home1",
        "city": "home1 city"
      },
      {
        "type": "home2",
        "city": "home2 city"
      }
    ]
  }
]

When I followed the solution that georg suggested:

resultarray = _(json1).concat(json2).groupBy('id').map(_.spread(_.assign)).value();

the "addresses" attribute is getting overridden, instead of being merged. How to merge 2 JSON objects using a unique key and deep merge the nested array objects using another child unique key("type")?

Non lodash solutions are also welcome!

Ali Esmailpor
  • 1,209
  • 3
  • 11
  • 22
rpd
  • 21
  • 4
  • I have a working solution but need to clean it up. Stay tuned (hopefully done in the next 24 hours) – vincent Jan 22 '21 at 01:38
  • 1
    Oh wow! That’s great news! Appreciate your help! – rpd Jan 22 '21 at 05:08
  • Very interesting question, I enjoyed crafting the answer. – vincent Jan 22 '21 at 06:24
  • You're welcome. Since you're new here, please don't forget to mark the answer accepted which helped most in solving the problem. See also How does accepting an answer work? – vincent Jan 28 '21 at 15:39
  • Sorry to hear about your arm! Hope you get better quickly! Check "How do I accept an answer, and what are the rules?" under https://meta.stackexchange.com/questions/5234/how-does-accepting-an-answer-work – vincent Feb 05 '21 at 17:11

2 Answers2

1

Here is a generic answer using object-lib.

// const objectLib = require('object-lib');

const { Merge } = objectLib;

const json1 = [{ id: 1, name: 'aaa', addresses: [{ type: 'office', city: 'office city' }, { type: 'home1', city: 'home1 city' }] }, { id: 2, name: 'bbb', addresses: [{ type: 'office', city: 'office city' }, { type: 'home1', city: 'home1 city' }] }];
const json2 = [{ id: 1, name: 'aaa1', addresses: [{ type: 'home1', city: 'home1 new city' }, { type: 'home2', city: 'home2 city' }] }, { id: 3, name: 'ccc', addresses: [{ type: 'home1', city: 'home1 city' }, { type: 'home2', city: 'home2 city' }] }];

const merge1 = Merge({
  '[*]': 'id',
  '[*].addresses[*]': 'type'
})
console.log(merge1(json1, json2));
// => [ { id: 1, name: 'aaa1', addresses: [ { type: 'office', city: 'office city' }, { type: 'home1', city: 'home1 new city' }, { type: 'home2', city: 'home2 city' } ] }, { id: 2, name: 'bbb', addresses: [ { type: 'office', city: 'office city' }, { type: 'home1', city: 'home1 city' } ] }, { id: 3, name: 'ccc', addresses: [ { type: 'home1', city: 'home1 city' }, { type: 'home2', city: 'home2 city' } ] } ]

// -----------

const d1 = { id: 1, other: [{ type: 'a', meta: 'X', prop1: true }] };
const d2 = { id: 1, other: [{ type: 'a', meta: 'Y', prop2: false }] };

console.log(Merge()(d1, d2))
// => { id: 1, other: [ { type: 'a', meta: 'X', prop1: true }, { type: 'a', meta: 'Y', prop2: false } ] }
console.log(Merge({ '**[*]': 'type' })(d1, d2))
// => { id: 1, other: [ { type: 'a', meta: 'Y', prop1: true, prop2: false } ] }
.as-console-wrapper {max-height: 100% !important; top: 0}
<script src="https://bundle.run/object-lib@2.0.0"></script>

Disclaimer: I'm the author of object-lib

Feel free to take a look at the source-code to get an idea how this works internally.

vincent
  • 1,953
  • 3
  • 18
  • 24
  • The new library looks useful. I'm still considering what I would want in a generic, array-handling `merge` function. Ramda's simply overwrites one array with another. But I can imagine at least three other strategies: the function/field selection in object-lib, simple concatenation, and merging the values at each index. And if I can think of four, I'm sure others can come up with different ones. I'm not happy with any API I've considered to deal with this, even were I to adopt object-scan's useful query syntax. I'm still thinking, but am not hopeful. – Scott Sauyet Jan 25 '21 at 02:02
  • I agree that having more options in the merge is probably very useful to handle more cases (should be easy to add). I like the current syntax for the fields, but didn't think to hard about it, so there might be a better one – vincent Jan 25 '21 at 02:11
  • 1
    I [wrote up my thoughts](https://gist.github.com/CrossEye/ac9ae1cafad6e29963c90c748620d848) on this and decided that there are simply too many API questions for me to tackle this in a generic way. Although I don't remember the decision, I wouldn't be surprised if this is why we decided to punt on this in Ramda. – Scott Sauyet Jan 25 '21 at 04:03
  • 1
    Great food for thought. I like your input and I think that api might solve the majority of cases. I'll probably implement a similar api going forward and we'll have to see what works and doesn't work for questions asked and revisit. – vincent Jan 28 '21 at 15:41
  • 1
    That will probably work fairly well alongside the object-scan query syntax. It would allow you to deal with arbitrary nesting of consistent recursive structures in a clean way. (I'm assuming so anyway. Is that right?) I think you would also have to allow for an override at the root level for the default array merge behavior, but that should be straightforward. – Scott Sauyet Jan 28 '21 at 16:19
0

Here's one approach. It assumes that you just want to merge the addresses and that other properties are kept intact. A general-purpose tool that merges all arrays would be somewhat more involved, and would not make it as easy to choose the correct address by type as done here. If you want such a tool, I haven't used it, but I saw recently in another answer one called deepmerge.

Here's my implementation:

const last = xs =>
  xs [xs.length - 1]

const group = (fn) => (xs) =>
  Object .values (xs .reduce (
    (a, x) => ((a [fn (x)] = [... (a [fn (x)] || []), x]), a),
    {}
  ))

const mergeAddresses = (as) => 
  as.reduce (({addresses: a1, ...rest1}, {addresses: a2, ...rest2}) => ({
    ...rest1,
    ...rest2,
    addresses:  group (a => a.type) ([...a1, ...a2]) .flatMap (last)
  }))

const combine = (xs, ys) => 
  group (x => x.id) ([...xs, ...ys]) .map (mergeAddresses)

const xs = [{id: 1, name: "aaa", addresses: [{type: "office", city: "office city"}, {type: "home1", city: "home1 city"}]}, {id: 2, name: "bbb", addresses: [{type: "office", city: "office city"}, {type: "home1", city: "home1 city"}]}]
const ys = [{id: 1, name: "aaa1", addresses: [{type: "home1", city: "home1 new city"}, {type: "home2", city: "home2 city"}]}, {id: 3, name: "ccc", addresses: [{type: "home1", city: "home1 city"}, {type: "home2", city: "home2 city"}]}]

console .log (combine (xs, ys))
.as-console-wrapper {max-height: 100% !important; top: 0}

We start with the trivial helper function last, which simply takes the last element of an array.

Next is the more substantive helper group. This is modeled after the API from Ramda's groupBy (disclaimer: I'm a Ramda author), which is fairly similar to the one from lodash But groupBy makes an indexed object out of an array. This then collects the values from that to turn it into an array of arrays. So while

groupBy(x => x.a)([{a: 1, b: 2}, {a:2, b: 5}, {a: 1, b: 7}, {a: 3, b: 8}, {a: 1, b: 9}])

yields

{
  1: [{a: 1, b: 2}, {a: 1, b: 7}, {a: 1, b: 9}], 
  2: [{a: 2, b: 5}], 
  3: [{a: 3, b: 8}]
}

this group function is slightly simpler:

group(x => x.a)([{a: 1, b: 2}, {a:2, b: 5}, {a: 1, b: 7}, {a: 3, b: 8}, {a: 1, b: 9}])

yields

[
  [{a: 1, b: 2}, {a: 1, b: 7}, {a: 1, b: 9}], 
  [{a: 2, b: 5}], 
  [{a: 3, b: 8}]
]

The group function might be worth keeping in a utility library. It has plenty of uses.

Next is a helper function, mergeAddresses, which takes a list of objects and combines their addresses arrays into a single array. The only complexity here is in handling the duplicated types. If we didn't have that concern, we could just write

    addresses:  [...a1, ...a2]

But here we do this:

    addresses:  group (a => a.type) ([...a1, ...a2]) .flatMap (last)

which groups the addresses by type and then takes the last one from each group.

Note that any properties other than addresses from id-matching entries are simply passed along, with same-named properties from the second one overwriting those from the first one. (You could always change this by swapping rest1 and rest2 in the output object.)

Finally our main function, combine, combines the arrays, uses group to group them by id and then calls mergeAddresses on each one.

Update

After discussions of ways to make this more generic, there is a request for one particular abstraction, making "id", "addresses", and "type" configurable. This is a fairly simple process, mechanical except for the choosing of parameter names. Here's an updated version:

const last = xs =>
  xs [xs.length - 1]

const group = (fn) => (xs) =>
  Object .values (xs .reduce (
    (a, x) => ((a [fn (x)] = [... (a [fn (x)] || []), x]), a),
    {}
  ))

const mergeChildren = (field, key) => (as) => 
  as.reduce (({[field]: a1, ...rest1}, {[field]: a2, ...rest2}) => ({
    ...rest1,
    ...rest2,
    [field]:  group (a => a[key]) ([...a1, ...a2]) .flatMap (last)
  }))

const combine = (parentKey, childField, childKey) => (xs, ys) => 
  group (x => x [parentKey]) ([...xs, ...ys]) .map (mergeChildren (childField, childKey))

const combineByAddressType = combine ('id', 'addresses', 'type')

const xs = [{id: 1, name: "aaa", addresses: [{type: "office", city: "office city"}, {type: "home1", city: "home1 city"}]}, {id: 2, name: "bbb", addresses: [{type: "office", city: "office city"}, {type: "home1", city: "home1 city"}]}]
const ys = [{id: 1, name: "aaa1", addresses: [{type: "home1", city: "home1 new city"}, {type: "home2", city: "home2 city"}]}, {id: 3, name: "ccc", addresses: [{type: "home1", city: "home1 city"}, {type: "home2", city: "home2 city"}]}]

console .log (combineByAddressType (xs, ys))
.as-console-wrapper {max-height: 100% !important; top: 0}

We rename the helper function to mergeChild, since this no longer has anything to do directly with addresses.

Depending upon how we want to use this, we can skip the partially applied helper function, combineByAddressType, and directly call combine ('id', 'addresses', 'type') (xs, ys)

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • Thanks, @vincent for the heads-up on an interesting problem. – Scott Sauyet Jan 22 '21 at 14:50
  • No worries! How would you solve this generically / dynamically? Ie having arrays at different levels that need to be merged by different fields? – vincent Jan 22 '21 at 15:18
  • 1
    @vincent: It's hard to envision a good API for that, never mind an implementation. I haven't really looked through yours yet, but I will do so soon, and maybe look at a generic concept over the weekend. – Scott Sauyet Jan 22 '21 at 15:57
  • I've just generified my answer. Definitely not trivial, but it's clean imo. – vincent Jan 22 '21 at 17:19
  • @ScottSauyet How can we make this solution generic? The data array names and unique keys are dynamic. – rpd Jan 26 '21 at 05:56
  • @rpd: I wrote [a description](https://gist.github.com/CrossEye/ac9ae1cafad6e29963c90c748620d848) on problems developing a generic API for this. If you can describe the API you are looking for (perhaps something like the one from vincent's answer?), then we could talk about how we might implement it. I have my doubts about creating a truly generic version of this in any useful way, but I'd like to see what you come up with. – Scott Sauyet Jan 26 '21 at 13:38
  • @scottsauyet I red your description, where it talks about merging the grandchildren, to keep it simple, we shall worry only about merging the child arrays. There would be 4 parameters to make this generic - 1. Data array name of the parent, 2. Unique key to merge, 3. Child data array name, 4. Child unique key to merge – rpd Feb 05 '21 at 13:26
  • @rpd: So we'd call with `2 - 'id'`, `3 - 'addresses'`, `4 - 'type'`, but what would `1` be for the above? (BTW, that sort of generifying is simple enough and entirely mechanical.) – Scott Sauyet Feb 05 '21 at 14:22
  • @rpd: Added an update that handles the three fields I understood. – Scott Sauyet Feb 05 '21 at 14:32