Let's fully answer the original question while reusing code that was already written (i.e., Underscore). You can do much more with Underscore if you combine its >100 functions. The following solution demonstrates this.
Step 1: group the objects in the array by an arbitrary combination of properties. This uses the fact that _.groupBy
accepts a function that returns the group of an object. It also uses _.chain
, _.pick
, _.values
, _.join
and _.value
. Note that _.value
is not strictly needed here, because chained values will automatically unwrap when used as a property name. I'm including it to safeguard against confusion in case somebody tries to write similar code in a context where automatic unwrapping does not take place.
// Given an object, return a string naming the group it belongs to.
function category(obj) {
return _.chain(obj).pick(propertyNames).values().join(' ').value();
}
// Perform the grouping.
const intermediate = _.groupBy(arrayOfObjects, category);
Given the arrayOfObjects
in the original question and setting propertyNames
to ['Phase', 'Step']
, intermediate
will get the following value:
{
"Phase 1 Step 1": [
{ Phase: "Phase 1", Step: "Step 1", Task: "Task 1", Value: "5" },
{ Phase: "Phase 1", Step: "Step 1", Task: "Task 2", Value: "10" }
],
"Phase 1 Step 2": [
{ Phase: "Phase 1", Step: "Step 2", Task: "Task 1", Value: "15" },
{ Phase: "Phase 1", Step: "Step 2", Task: "Task 2", Value: "20" }
],
"Phase 2 Step 1": [
{ Phase: "Phase 2", Step: "Step 1", Task: "Task 1", Value: "25" },
{ Phase: "Phase 2", Step: "Step 1", Task: "Task 2", Value: "30" }
],
"Phase 2 Step 2": [
{ Phase: "Phase 2", Step: "Step 2", Task: "Task 1", Value: "35" },
{ Phase: "Phase 2", Step: "Step 2", Task: "Task 2", Value: "40" }
]
}
Step 2: reduce each group to a single flat object and return the results in an array. Besides the functions we have seen before, the following code uses _.pluck
, _.first
, _.pick
, _.extend
, _.reduce
and _.map
. _.first
is guaranteed to return an object in this case, because _.groupBy
does not produce empty groups. _.value
is necessary in this case.
// Sum two numbers, even if they are contained in strings.
const addNumeric = (a, b) => +a + +b;
// Given a `group` of objects, return a flat object with their common
// properties and the sum of the property with name `aggregateProperty`.
function summarize(group) {
const valuesToSum = _.pluck(group, aggregateProperty);
return _.chain(group).first().pick(propertyNames).extend({
[aggregateProperty]: _.reduce(valuesToSum, addNumeric)
}).value();
}
// Get an array with all the computed aggregates.
const result = _.map(intermediate, summarize);
Given the intermediate
that we obtained before and setting aggregateProperty
to Value
, we get the result
that the asker desired:
[
{ Phase: "Phase 1", Step: "Step 1", Value: 15 },
{ Phase: "Phase 1", Step: "Step 2", Value: 35 },
{ Phase: "Phase 2", Step: "Step 1", Value: 55 },
{ Phase: "Phase 2", Step: "Step 2", Value: 75 }
]
We can put this all together in a function that takes arrayOfObjects
, propertyNames
and aggregateProperty
as parameters. Note that arrayOfObjects
can actually also be a plain object with string keys, because _.groupBy
accepts either. For this reason, I have renamed arrayOfObjects
to collection
.
function aggregate(collection, propertyNames, aggregateProperty) {
function category(obj) {
return _.chain(obj).pick(propertyNames).values().join(' ');
}
const addNumeric = (a, b) => +a + +b;
function summarize(group) {
const valuesToSum = _.pluck(group, aggregateProperty);
return _.chain(group).first().pick(propertyNames).extend({
[aggregateProperty]: _.reduce(valuesToSum, addNumeric)
}).value();
}
return _.chain(collection).groupBy(category).map(summarize).value();
}
aggregate(arrayOfObjects, ['Phase', 'Step'], 'Value')
will now give us the same result
again.
We can take this a step further and enable the caller to compute any statistic over the values in each group. We can do this and also enable the caller to add arbitrary properties to the summary of each group. We can do all of this while making our code shorter. We replace the aggregateProperty
parameter by an iteratee
parameter and pass this straight to _.reduce
:
function aggregate(collection, propertyNames, iteratee) {
function category(obj) {
return _.chain(obj).pick(propertyNames).values().join(' ');
}
function summarize(group) {
return _.chain(group).first().pick(propertyNames)
.extend(_.reduce(group, iteratee)).value();
}
return _.chain(collection).groupBy(category).map(summarize).value();
}
In effect, we move some of the responsibility to the caller; she must provide an iteratee
that can be passed to _.reduce
, so that the call to _.reduce
will produce an object with the aggregate properties she wants to add. For example, we obtain the same result
as before with the following expression:
aggregate(arrayOfObjects, ['Phase', 'Step'], (memo, value) => ({
Value: +memo.Value + +value.Value
}));
For an example of a slightly more sophisticated iteratee
, suppose that we want to compute the maximum Value
of each group instead of the sum, and that we want to add a Tasks
property that lists all the values of Task
that occur in the group. Here's one way we can do this, using the last version of aggregate
above (and _.union
):
aggregate(arrayOfObjects, ['Phase', 'Step'], (memo, value) => ({
Value: Math.max(memo.Value, value.Value),
Tasks: _.union(memo.Tasks || [memo.Task], [value.Task])
}));
We obtain the following result:
[
{ Phase: "Phase 1", Step: "Step 1", Value: 10, Tasks: [ "Task 1", "Task 2" ] },
{ Phase: "Phase 1", Step: "Step 2", Value: 20, Tasks: [ "Task 1", "Task 2" ] },
{ Phase: "Phase 2", Step: "Step 1", Value: 30, Tasks: [ "Task 1", "Task 2" ] },
{ Phase: "Phase 2", Step: "Step 2", Value: 40, Tasks: [ "Task 1", "Task 2" ] }
]
Credit to @much2learn, who also posted an answer that can handle arbitrary reducing functions. I wrote a couple more SO answers that demonstrate how one can achieve sophisticated things by combining multiple Underscore functions:
function groupBy(data, key){ return data.reduce( (acc, cur) => { acc[cur[key]] = acc[cur[key]] || []; // if the key is new, initiate its value to an array, otherwise keep its own array value acc[cur[key]].push(cur); return acc; } , []) }
– Periodical