Posted at
MongoDBDay 9

MongoDB 3.2 で追加された $lookup ステージを使ってみる

More than 3 years have passed since last update.

ややずるい気もしますが,仕事でどうしても使いたいというニーズも多く,ハンズオン的にやってみます.

利用するのは 2015/11/13時点 (原稿書き始め) で最新の 3.2.0-rc2 です.

これ専用に建てた CentOS 7.0 on Vagrant で動かしているので,バイナリの置き場などは雑です.

$ wget https://fastdl.mongodb.org/linux/mongodb-linux-x86_64-rhel70-3.2.0-rc2.tgz

$ tar -zxvf mongodb-linux-x86_64-rhel70-3.2.0-rc2.tgz
$ sudo mkdir -p /opt/mongodb
$ cp -R -n mongodb-linux-x86_64-rhel70-3.2.0-rc2 /opt/mongodb
$ echo PATH=/opt/mongodb/mongodb-linux-x86_64-rhel70-3.2.0-rc2/bin:$PATH
$ mongod --version
# 以下の様な感じで表示されれば OK!
# db version v3.2.0-rc2
# git version: 8a3acb42742182c5e314636041c2df368232bbc5
# OpenSSL version: OpenSSL 1.0.1e-fips 11 Feb 2013
# allocator: tcmalloc
# modules: none
# build environment:
# distmod: rhel70
# distarch: x86_64
# target_arch: x86_64

よし,起動しよう.

$ mkdir -p ~/mongodb/data ~/mongodb/log

$ mongod --dbpath ~/mongodb/data --logpath ~/mongodb/log/mongod.log --fork
$ mongo # シェルが始まれば良い.

サンプル分析用のファイルを投入する.今回は,2015年のプロ野球セントラルリーグの規定打席到達選手の打撃成績データと,選手のプロフィールデータです.

データは以下に配置しました.

players.json: https://gist.github.com/Mura-Mi/a492ecce742d86c5e5bd

hitters_stats.json: https://gist.github.com/Mura-Mi/40ce800ec6f3f2372129

$ mongoimport --db central --collection players --file players.json # 選手データの投入

# 2015-11-13T16:12:35.839+0000 connected to: localhost
# 2015-11-13T16:12:35.864+0000 imported 24 documents
$
$ mongoimport --db central --collection stats --file hitters_stats.json
$
$ mongo localhost:27017 # MongoDB Shell を起動する.

ちゃんと入っているか,クエリを打って調べてみる.

use central

db.teams.find();
// 以下の様な感じで出力されれば OK
// { "_id" : ObjectId("56460d554936431a75165ab2"), "name" : "Yakult Swallows", "abbreviation" : "ヤ" }
// { "_id" : ObjectId("56460d554936431a75165ab3"), "name" : "Yomiuri Giants", "abbreviation" : "巨" }
// ...
db.teams.count()
// => 6
db.batters.find({}, {_id: 0, name: 1, team: 1})
// 以下のようになれば OK.
// { "name" : "川端慎吾", "team" : "ヤ" }
// { "name" : "山田哲人", "team" : "ヤ" }
// { "name" : "筒香嘉智", "team" : "D" }
// ...
db.batters.count()
// => 24

では,$lookup してみる.まずは,単純に Look Up するだけで,そこからの集計処理をしない.

db.hitting_stats.aggregate(

[
{$lookup: {
from: 'players', // どのコレクションを結合するか
localField: 'name', // 集計対象のコレクション (hitting_stats) のどのフィールド?
foreignField: 'name', // 結合対象のコレクション (player) のどのフィールド?
as: 'profile' // 集計対象のコレクションのなんというフィールドをキーとして結合する?
}
}
]
);

この結果は,例えば1つのドキュメントは以下のようになる.

{ 

"_id" : ObjectId("564764654936431a75165af4"),
"name" : "川端慎吾",
"games" : 143,
"plate" : 632,
...(中略)...,
"profile" : [
{
"_id" : ObjectId("564761794936431a75165ac4"),
"name" : "川端慎吾",
"team" : "Ys",
"number" : 5,
"born_in" : "大阪府"
}
]
}

なるほど,きちんと profile フィールドに player コレクションのドキュメントが埋め込まれていますが,profile キーに紐づく値は配列になっています.条件の当てはまったドキュメントが複数あった場合にはすべてこの配列に含まれるわけですね.

(業務的に一意に定められることがわかっている場合は,この後に $unwind ステージを挟んで配列からスカラ値に噛み砕けば良いのかな?しかし,一意になるべきなのに実は一意ではないデータがストアされているときに,一見わかりづらいバグになりそうですね… Schema Validation で制御すればよいのかな?)

よく $lookup をして ついに MongoDB で JOIN が出来るようになった!と言われることもありますが,実際には一対多でドキュメント同士を紐付けるし,フィールドに紐づくキー同士を紐付けます.また,指定されたフィールドが存在しない場合,そのフィールドの値は暗黙的に null として扱われ,null 同士でマッチしたら JOIN されてしまうので,SQL の JOIN ほど柔軟なわけではないようですね.

では,選手の出身地ごとの本塁打数合計を出して,多い順に並べてみます.

db.hitting_stats.aggregate(

[
{
$lookup: {
from: 'players',
localField: 'name',
foreignField: 'name',
as: 'profile'
}
},
{ $unwind: "$profile" },
{
$group: {
_id: "$profile.born_in",
"HR": {$sum: "$homerun"}
}
},
{
$sort: {
HR: -1
}
}
]
);

すると,こんな結果が得られます.

{ "_id" : [ "兵庫県" ], "HR" : 50 }

{ "_id" : [ "ベネズエラ" ], "HR" : 38 }
{ "_id" : [ "ドミニカ共和国" ], "HR" : 36 }
{ "_id" : [ "岩手県" ], "HR" : 26 }
{ "_id" : [ "和歌山県" ], "HR" : 24 }
{ "_id" : [ "大阪府" ], "HR" : 21 }
{ "_id" : [ "鹿児島県" ], "HR" : 20 }
{ "_id" : [ "千葉県" ], "HR" : 19 }
{ "_id" : [ "神奈川県" ], "HR" : 16 }
{ "_id" : [ "佐賀県" ], "HR" : 15 }
{ "_id" : [ "東京都" ], "HR" : 14 }
{ "_id" : [ "島根県" ], "HR" : 13 }
{ "_id" : [ "広島県" ], "HR" : 11 }
{ "_id" : [ "アメリカ合衆国" ], "HR" : 9 }
{ "_id" : [ "愛知県" ], "HR" : 6 }
{ "_id" : [ "福井県" ], "HR" : 2 }

選手数が多くないのでちょっと微妙ですが…やはり山田哲人が兵庫を引っ張ってますね.外国人事情は北米でなく中南米なんですね.

ということで,$lookup を使ってみるの巻き,でした.