Ramda で階層的なグルーピング

JavaScript 用の関数型ライブラリ Ramda で階層的なグルーピングを行ってみます。

ソースは http://github.com/fits/try_samples/tree/master/blog/20180220/

はじめに

概要

今回は、以下のデータに対して階層的なグルーピングと集計処理を適用します。

データ
const data = [
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A02', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 20},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-02', value: 1},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 5},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-03', value: 3},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 1},
    {category: 'C', item: 'C01', date: '2018-02-01', value: 1},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 10}
]

具体的には、上記category item date の順に階層的にグルーピングした後、value の合計値を算出して以下のようにします。

処理結果
{
  A: {
     A01: { '2018-02-01': 2, '2018-02-02': 22 },
     A02: { '2018-02-01': 1 },
     A03: { '2018-02-03': 7 }
  },
  B: { B01: { '2018-02-02': 1, '2018-02-03': 3, '2018-02-04': 11 } },
  C: { C01: { '2018-02-01': 1 } }
}

Ramda インストール

Ramda は以下のようにインストールしておきます。

> npm install ramda

実装

(a) 階層的なグルーピングと集計

まずは、処理方法を確認するため、順番に処理を実施してみます。

1. category でグルーピング(1層目)

指定項目によるグルーピング処理は R.groupBy で行えます。 category でグルーピングする処理は以下のようになります。

category グルーピング処理
const R = require('ramda')

const data = [
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A02', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 20},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-02', value: 1},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 5},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-03', value: 3},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 1},
    {category: 'C', item: 'C01', date: '2018-02-01', value: 1},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 10}
]

const res1 = R.groupBy(R.prop('category'), data)

console.log(res1)
category グルーピング結果
{ A:
   [ { category: 'A', item: 'A01', date: '2018-02-01', value: 1 },
     { category: 'A', item: 'A02', date: '2018-02-01', value: 1 },
     { category: 'A', item: 'A01', date: '2018-02-01', value: 1 },
     { category: 'A', item: 'A01', date: '2018-02-02', value: 20 },
     { category: 'A', item: 'A03', date: '2018-02-03', value: 2 },
     { category: 'A', item: 'A03', date: '2018-02-03', value: 5 },
     { category: 'A', item: 'A01', date: '2018-02-02', value: 2 } ],
  B:
   [ { category: 'B', item: 'B01', date: '2018-02-02', value: 1 },
     { category: 'B', item: 'B01', date: '2018-02-03', value: 3 },
     { category: 'B', item: 'B01', date: '2018-02-04', value: 1 },
     { category: 'B', item: 'B01', date: '2018-02-04', value: 10 } ],
  C:
   [ { category: 'C', item: 'C01', date: '2018-02-01', value: 1 } ] }

2. item でグルーピング(2層目)

category のグルーピング結果を item で更にグルーピングするには res1 の値部分 ([ { category: 'A', ・・・}, ・・・ ] 等) に R.groupBy を適用します。

これは R.mapObjIndexed で実施できます。

item グルーピング処理
const res2 = R.mapObjIndexed(R.groupBy(R.prop('item')), res1)

console.log(res2)
item グルーピング結果
{ A:
   { A01: [ [Object], [Object], [Object], [Object] ],
     A02: [ [Object] ],
     A03: [ [Object], [Object] ] },
  B: { B01: [ [Object], [Object], [Object], [Object] ] },
  C: { C01: [ [Object] ] } }

3. date でグルーピング(3層目)

更に date でグルーピングするには R.mapObjIndexed を重ねて R.groupBy を適用します。

date グルーピング処理
const res3 = R.mapObjIndexed(R.mapObjIndexed(R.groupBy(R.prop('date'))), res2)

console.log(res3)
date グルーピング結果
{ A:
   { A01: { '2018-02-01': [Array], '2018-02-02': [Array] },
     A02: { '2018-02-01': [Array] },
     A03: { '2018-02-03': [Array] } },
  B:
   { B01:
      { '2018-02-02': [Array],
        '2018-02-03': [Array],
        '2018-02-04': [Array] } },
  C: { C01: { '2018-02-01': [Array] } } }

4. value の合計

最後に、R.groupBy の代わりに value を合計する処理(以下の sumValue)へ R.mapObjIndexed を階層分だけ重ねて適用すれば完成です。

value 合計処理
const sumValue = R.reduce((a, b) => a + b.value, 0)

const res4 = R.mapObjIndexed(R.mapObjIndexed(R.mapObjIndexed(sumValue)), res3)

console.log(res4)
value 合計結果
{ A:
   { A01: { '2018-02-01': 2, '2018-02-02': 22 },
     A02: { '2018-02-01': 1 },
     A03: { '2018-02-03': 7 } },
  B: { B01: { '2018-02-02': 1, '2018-02-03': 3, '2018-02-04': 11 } },
  C: { C01: { '2018-02-01': 1 } } }

(b) N階層のグルーピングと集計

次は、汎用的に使えるような実装にしてみます。

任意の処理に対して指定回数だけ R.mapObjIndexed を重ねる処理があると便利なので applyObjIndexedN として実装しました。

(a) で実施したように、階層的なグルーピングは R.mapObjIndexed を階層分重ねた R.groupBy を繰り返し適用していくだけですので R.reduce で実装できます。(以下の groupByMulti

ちなみに、階層的にグルーピングする実装例は Ramda の Cookbook(groupByMultiple) にありましたが、変数へ再代入したりと手続き的な実装内容になっているのが気になりました。

sample.js
const R = require('ramda')

const data = [
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A02', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 20},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-02', value: 1},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 5},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-03', value: 3},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 1},
    {category: 'C', item: 'C01', date: '2018-02-01', value: 1},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 10}
]

/* 
  指定回数(n)だけ R.mapObjIndexed を重ねた任意の処理(fn)を
  data を引数にして実行する処理
*/
const applyObjIndexedN = R.curry((n, fn, data) =>
    R.reduce(
        (a, b) => R.mapObjIndexed(a), 
        fn, 
        R.range(0, n)
    )(data)
)

// 階層的なグルーピング処理
const groupByMulti = R.curry((fields, data) => 
    R.reduce(
        (a, b) => applyObjIndexedN(b, R.groupBy(R.prop(fields[b])), a),
        data, 
        R.range(0, fields.length)
    )
)

const cols = ['category', 'item', 'date']

const sumValue = R.reduce((a, b) => a + b.value, 0)

const sumMultiGroups = R.pipe(
    groupByMulti(cols), // グルーピング処理
    applyObjIndexedN(cols.length, sumValue) // 合計処理
)

console.log( sumMultiGroups(data) )
実行結果
> node sample.js

{ A:
   { A01: { '2018-02-01': 2, '2018-02-02': 22 },
     A02: { '2018-02-01': 1 },
     A03: { '2018-02-03': 7 } },
  B: { B01: { '2018-02-02': 1, '2018-02-03': 3, '2018-02-04': 11 } },
  C: { C01: { '2018-02-01': 1 } } }