JavaScriptでもLINQを使って集計処理を簡単に実装しよう!

LINQは.NETだけのものではありません!
2020.01.23

CX事業本部@大阪の岩田です。

先日大阪オフィスのチャット部屋でこんなやりとりがありました。

[
  {
    "event_code": "deviocafe_wt-test",
    "person_id": 76,
    "transaction_no": "019"
  },
  {
    "event_code": "deviocafe_wt-test",
    "person_id": 74,
    "transaction_no": "019"
  },
  {
    "event_code": "deviocafe_wt-test",
    "person_id": 75,
    "transaction_no": "018"
  }
]

というデータをtransaction_noでグルーピング&person_idをマージして

[
  {
    "transaction_no": "019",
    "event_code": "deviocafe_wt-test",
    "person_id": [76, 74]
  },
  {
    "transaction_no": "018",
    "event_code": "deviocafe_wt-test",
    "person_id": [75]
  }
]

のような形式に変換したいという要件です。.NETだったらLINQで楽勝なのになー...とか考えながら、そういえばJavaScriptやTypeScriptでLINQが使えるライブラリって無いのかな?と調べたところ、linqというそのまんまなライブラリが見つかりました。

linqについて

.NETのLINQをJavaScript向けに実装したライブラリです。元々は以下のリポジトリで開発されていたようですが、こちらは既にアーカイブされています。なんと10年以上前から存在するようです。まだJavaScriptでできることが少なかった時代...さぞ便利だったことでしょう。

https://github.com/neuecc/linq.js

現在は上記リポジトリからフォークした以下のリポジトリでメンテナンスされており、npmからインストールすることも可能です。

https://github.com/mihaifm/linq

やってみる

実際にlinqを使って、先程のグルーピング処理を実装してみましょう。とりあえずlinqをインストールします。

npm install linq

以下のコードで要件を満たすグルーピング処理が可能です。

const Enumerable = require('linq');

const objects =
  [{
    event_code: 'deviocafe_wt-test',
    person_id: 76,
    transaction_no: '019'
  }, {
    event_code: 'deviocafe_wt-test',
    person_id: 74,
    transaction_no: '019'
  }, {
    event_code: 'deviocafe_wt-test',
    person_id: 75,
    transaction_no: '018'
  }];

const res = Enumerable.from(objects)
  .groupBy(
    x => x.transaction_no,
    null,
    ( key, g ) => {
      return {
        transaction_no: key,
        event_code: g.first().event_code,
        person_id: g.select(x => x.person_id).toArray()
      }
    }
  )
  .toArray();

console.log(res);

少し段階を追って見ていきましょう。まずEnumerable.from(objects)の部分で元データからIEnumerableインターフェースを実装したコレクションを作成します。次にgroupByメソッドで元データのグルーピングを行います。groupByに渡している各引数の意味は以下の通りです。

  • まず第一引数にはグルーピングに使用するキーを返す関数を渡してやります。関数の引数にはコレクション内の各要素が渡ってきます。今回はtransaction_noでグルーピングしたいので、x => x.transaction_noを指定しています。

  • 第二引数はちょっと説明が難しいのですが、第三引数の関数からグルーピング結果にアクセスするためのelementSelectorと呼ばれる関数を指定します。ここは実際の挙動を見たほうが理解しやすいと思うので、後ほど改めて説明します。ここの指定次第で後続処理の記述を簡略化できる可能性がありますが、今回は一旦指定なし(null)でやってみます。

  • 第三引数にはgroupByの結果を生成するための関数を渡します。ここで指定した関数の第一引数にはグループのキーが、第二引数にはIEnumerableインターフェースを実装するグルーピング後のオブジェクトが渡ってきます。今回はtransaction_noをキーにグルーピングしているので、(key ,g)=> ...で定義している関数のkeyにはtransaction_noの値が、gには元データをtransaction_noでグルーピングした後のオブジェクトが渡ってきます。引数で渡ってきたこれらのデータから、元々の要件にあった構造にデータを整形して返します。レスポンスのオブジェクトに返して欲しいのはtransaction_noevent_codeperson_id(person_idsの方が良かったかも?)です。transaction_noはグルーピングに使用したキー項目なので、keyを指定します。続いてevent_codeにはg.first().event_codeでグルーピングしたオブジェクトの1要素目のevent_codeを指定します。今回扱うデータはtransaction_noが同一であれば必ずevent_codeも同一になるので、このような指定にしています。最後のperson_idですが、今回の要件では各グループ内の全要素からperson_idを取得してArrayに変換したデータをセットしたい箇所です。g.select(x => x.person_id)でグループ内の各要素からperson_idを取得し、最後にtoArray()でArrayに変換します。

上記のコードを実行してgroupByの結果をtoArray()でArrayに変換すればデータの整形完了です。上記のコードを実際に実行すると、コンソールに以下のように出力されます。

[ { transaction_no: '019',
    event_code: 'deviocafe_wt-test',
    person_id: [ 76, 74 ] },
  { transaction_no: '018',
    event_code: 'deviocafe_wt-test',
    person_id: [ 75 ] } ]

無事に欲しいデータが取得できました!!とってもラクチンですね。

他にも集計処理を試してみる

せっかくなので他にもいくつか便利機能を試してみましょう。集計に使う元データです

店舗 注文内容 合計金額
秋葉原 ホットコーヒー,ホットコーヒー,ホットコーヒー,ホットコーヒー 920
秋葉原 アイスコーヒー, アイスカフェモカ,アイスティー 680
上越 ホットコーヒー,アイスコーヒー,コーラ 690
上越 コーラ,マンゴーソーダ 680
秋葉原 ホットコーヒー,ホットティー,アイスティー 790

このデータを元に、グルーピング処理を試してみます。まず上記のデータをJavaScriptのオブジェクトとして用意します。

products.js

const orders =
  [{
    place: '秋葉原',
    products: ['ホットコーヒー', 'ホットコーヒー', 'ホットコーヒー', 'ホットコーヒー'],
    amounts: 920
  }, {
    place: '秋葉原',
    products: ['アイスコーヒー', 'アイスカフェモカ', 'アイスティー'],
    amounts: 680
  }, {
    place: '上越',
    products: ['ホットコーヒー', 'アイスコーヒー', 'コーラ'],
    amounts: 690
  }, {
    place: '上越',
    products: ['コーラ', 'マンゴーソーダ'],
    amounts: 680
  },{
    place: '秋葉原',
    products: ['ホットコーヒー', 'ホットティー', 'アイスティー'],
    amounts: 790
  }
];

module.exports = orders;

店舗別に合計金額の最小値、最大値、平均値、合計の算出

定番の最小値、最大値、平均値、合計です

const Enumerable = require('linq');
const orders = require('./products');

const res = Enumerable.from(orders)
  .groupBy(
    x => x.place,
    x => x.amounts,
    ( key, g ) => {
      return {
        place: key,
        count: g.count(),
        min: g.min(),
        max: g.max(),
        avg: g.average(),
        sum: g.sum(),
      }
    }
  )
  .toArray();

console.log(res);

今回はgropByの第二引数にx => x.amountsを指定しています。こう指定しておくことで、第三引数の関数内のminmaxといったメソッドでは単にmin()max()といった記述で対象グループのamountsに対して集計をかけることができます。groupByの第二引数をnullを指定していた場合、第三引数の関数の中ではmin(x => x.amounts)max(x => x.amounts)のように指定する必要があります。

実行結果です。

[ { place: '秋葉原',
    count: 3,
    min: 680,
    max: 920,
    avg: 796.6666666666666,
    sum: 2390 },
  { place: '上越', count: 2, min: 680, max: 690, avg: 685, sum: 1370 } ]

ラクチン!

配列をフラットに展開しつつ複数項目でグルーピングして商品のランキングを作成

今度は店舗別に人気商品を分析してみましょう。商品の情報は各注文情報の内部に配列で保持しているので、商品情報を展開しつつ集計します。

const Enumerable = require('linq');
const orders = require('./products');


const res = Enumerable.from(orders)
  .selectMany(
    x => x.products,
    (value, product) => {
      return {
        place: value.place,
        product: product
      }
    }
  )
  .groupBy(
    x => {
      return  {
        place: x.place,
        product: x.product
      };
    },
    null,
    (key, grp) => {
      return {
        place: key.place ,
        product: key.product ,
        count: grp.count()
      }
    },
    (x) => {
      return x.place + '-' + x.product ;
    }
  )
  .orderBy(x => x.place)
  .thenByDescending(x => x.count)
  .toArray();
  
console.log(res);

処理のステップを1つづつ見ていきましょう

配列を展開する

元データのproductsには商品情報の配列がセットされているので、selectManyを使って一件づつの情報に展開します。また展開した後は商品の情報だけでなく店舗の情報も欲しいので

.selectMany(
    x => x.products,
    (value, product) => {
      return {
        place: value.place,
        product: product
      }
    }
  )

と指定し、placeproductを持つオブジェクトとして取得します。selectManyした直後の途中経過をtoArray()すると以下のような状態です。

[ { place: '秋葉原', product: 'ホットコーヒー' },
  { place: '秋葉原', product: 'ホットコーヒー' },
  { place: '秋葉原', product: 'ホットコーヒー' },
  { place: '秋葉原', product: 'ホットコーヒー' },
  { place: '秋葉原', product: 'アイスコーヒー' },
  { place: '秋葉原', product: 'アイスカフェモカ' },
  { place: '秋葉原', product: 'アイスティー' },
  { place: '上越', product: 'ホットコーヒー' },
  { place: '上越', product: 'アイスコーヒー' },
  { place: '上越', product: 'コーラ' },
  { place: '上越', product: 'コーラ' },
  { place: '上越', product: 'マンゴーソーダ' },
  { place: '秋葉原', product: 'ホットコーヒー' },
  { place: '秋葉原', product: 'ホットティー' },
  { place: '秋葉原', product: 'アイスティー' } ]

5件の注文情報が15件分の商品情報に展開されているのが分かります。

グルーピングする

さらに店舗と商品名でgroupByを行います。

.groupBy(
  x => {
    return  {
      place: x.place,
      product: x.product
    };
  },
  null,
  (key, grp) => {
    return {
      place: key.place ,
      product: key.product ,
      count: grp.count()
    }
  },
  (x) => {
    return x.place + '-' + x.product ;
  }
)

今回は店舗と商品という2つの項目をグルーピングのキーに使用したいので、groupByの第一引数は

x => {
  return  {
    place: x.place,
    product: x.product
  };
}

としてplaceproductを持つオブジェクトを返すようにします。第二引数は

(key, grp) => {
  return {
    place: key.place ,
    product: key.product ,
    count: grp.count()
  }
}

として店舗、商品、対象グループの件数を返します。さらに第四引数として

(x) => {
  return x.place + '-' + x.product ;
}

を指定しています。この第四引数にはIEnumerableな要素をグルーピングする際に、各要素が同一のグループに入る判定するためのキー項目を返すcompareSelectorという関数を指定します。今回第一引数のグループキーにはオブジェクトを指定しているため、各プロパティの値が同一であっても参照先が異なると

>{ place: '秋葉原', product: 'ホットコーヒー' } == { place: '秋葉原', product: 'ホットコーヒー' }
< false

のように不一致と判断されます。そのため場所と商品名を-で結合した文字列を返すように指定することで回避しています。本当は

(a, b) => a.place === b.place && a.product === b.product

のように指定したいのですが、仕様上できないようだったので文字列結合に逃げました。

groupByまでの結果をtoArray()すると以下のような状態になります。

[ { place: '秋葉原', product: 'ホットコーヒー', count: 5 },
  { place: '秋葉原', product: 'アイスコーヒー', count: 1 },
  { place: '秋葉原', product: 'アイスカフェモカ', count: 1 },
  { place: '秋葉原', product: 'アイスティー', count: 2 },
  { place: '上越', product: 'ホットコーヒー', count: 1 },
  { place: '上越', product: 'アイスコーヒー', count: 1 },
  { place: '上越', product: 'コーラ', count: 2 },
  { place: '上越', product: 'マンゴーソーダ', count: 1 },
  { place: '秋葉原', product: 'ホットティー', count: 1 } ]

ソートする

ここまでの実行結果は未ソート状態なので、最後に店舗と件数でソートを行います。

.orderBy(x => x.place)
.thenByDescending(x => x.count)

まずは.orderBy(x => x.place)で店舗別にソートします。同一店舗のオブジェクトをさらに件数でソートするためにthenByDescendingをつないで.thenByDescending(x => x.count)と指定します。これで店舗別の人気商品ランキングが完成です!

実行結果

[ { place: '上越', product: 'コーラ', count: 2 },
  { place: '上越', product: 'ホットコーヒー', count: 1 },
  { place: '上越', product: 'アイスコーヒー', count: 1 },
  { place: '上越', product: 'マンゴーソーダ', count: 1 },
  { place: '秋葉原', product: 'ホットコーヒー', count: 5 },
  { place: '秋葉原', product: 'アイスティー', count: 2 },
  { place: '秋葉原', product: 'アイスコーヒー', count: 1 },
  { place: '秋葉原', product: 'アイスカフェモカ', count: 1 },
  { place: '秋葉原', product: 'ホットティー', count: 1 } ]

無事に取得できました。

まとめ

JavaScriptでLINQが使えるライブラリlinqのご紹介でした。最近はJavaScript(というかECMAScriptか?)の進歩が早く、数年前と比べるとiterableなデータに対する諸々の操作が非常に便利になりました。しかしながら、グルーピング等のユースケースに関しては、まだまだ自前でコードを実装しないといけない領域も残っており、そういったユースケースでは今回紹介したlinqを利用することでiterableなデータをより簡単に操作できる可能性があります。是非一度お試した下さい。歴史あるライブラリですが、まだまだ現役で活躍できるパワーを持っていると思います!

参考