こちらの記事は「MEDLEY Summer Tech Blog Relay」の21日目の記事です。
結論
先に結論を置いておきます。
Mongoid(8.1.10 で確認)の has_many に並び順を持たせるなら、order: ではなく scope: に書いてください。
# includes(eager load)経由だと無視される
has_many :output_files, order: { _id: :asc }
# 通常アクセス / includes のどちらでも効く
has_many :output_files, scope: -> { order_by(_id: 1) }
最新のMongoid(9.1.0) でもincludes の挙動は同じでした。
https://github.com/mongodb/mongoid/blob/master/lib/mongoid/association/eager.rb
また、Mongoid 9.1.0 では $lookup を使うCriteria#eager_load が新たに追加されており、こちらは order: を反映するみたいです。
https://github.com/mongodb/mongoid/blob/v9.1.0/lib/mongoid/association/eager_loadable.rb
...でこの記事はここで終われるんですが、今回いちばん怖かったのはこの一行ではありません。
この違いはテストを書いても緑のまま通ってしまうことがあるという点です。むしろそっちが本編です。
順番、誰が保証してるんですか...
設定画面で追加した項目を、追加した順に並べて表示する。
要件としては1行で終わります。
そして has_many に並び順を書かなくても、一覧はたいてい期待通りに並びます。
たいてい並ぶ、というのが厄介なところです。
並び順を指定しないとき、MongoDB が返してくるのは natural order です。
https://www.mongodb.com/ja-jp/docs/manual/reference/method/cursor.sort/
$natural パラメーターを使うと、データベース内の自然な順序に従って項目を返します。この順序付けは内部実装機能であり、ドキュメントが特定の順序になることを前提にしないでください。
ともある通り、返してくるのはデータの挿入順ではありません。
加えて、読み取り負荷を分散するために secondary(レプリカ) を参照する構成では、secondary は primary とわずかに状態がずれることもあります。
つまり MongoDB は並び順を保証しません。
「並んで見えている」は「並ぶように書いた」の証拠になりません。
条件が揃えば入れ替わりうるし、そのとき再現手順を書くこともできません。
いちばん厄介なタイプです。
材料自体は揃っています。
入力された配列順に create! していれば、_id(ObjectId)は生成時刻を含むので単調増加する。
_id 昇順 = 作成順 = 入力順は成立しているので、後は読み取るときにそう頼むだけ......
素直な修正
というわけで、model の関連定義に並び順を足します。
has_many :output_files, order: { _id: :asc }, class_name: '...', dependent: :destroy
呼び出しのたびに order を書くのではなく関連定義に一箇所書きました。
これで取得経路が増えても漏れません。ドキュメントにも載っているオプションです。
order: と書けば効く。そう思うのが自然です。
(-皿-) <勝ったな
ー■ー■ー <あぁ
……となるはずでした。
進路(テスト)クリア。オールグリーン...?
条件が揃ったときにしか出ない性質のものなので、テストで固めておきたい。
一覧画面は includes でまとめて取ってくるので、その経路で順序が固定されることを確認します。
before do
create(:output_file, setting: setting, name: 'A')
create(:output_file, setting: setting, name: 'B')
create(:output_file, setting: setting, name: 'C')
end
it 'includes でも _id 昇順で返る' do
reloaded = Setting.includes(:output_files).find(setting.id)
expect(reloaded.output_files.map(&:name)).to eq %w[A B C]
end
緑。( ΦωΦ)σ ヨシ!
ここで、ひとつ儀式を行いました。
さっき足した order: の行を消して、もう一回走らせる。
赤くなるに決まってるでしょ。
並び順の指定、消したんだから。
当然じゃない、そんなの見ればわかるわよ。
1 example, 0 failures
まだ緑でした。
え?
何も検証していなかった
種明かしをすると、こうです。
前述の通り、MongoDB は並び順を指定しない場合、返却順を保証しません。
ただし保証がないというだけで、挿入しかしていない小さなコレクションなら、実際にはたいてい挿入順で返ってきます。
そして挿入順は _id 昇順です。
一方このテストが期待しているのは %w[A B C]、つまり _id 昇順。
期待している順番と、何もしなくても返ってくる順番が、一致してしまっていた。
だから order: があってもなくても緑になります。
つまりこのテストは、順序を検証しているつもりで何も検証していません。
再現しない挙動を、緑になるテストで守った気になる。
いちばん危ないパターンです。
テストに仕事をさせる
直し方はシンプルで、自然順と期待順をわざと食い違わせます。
before do
# _id 昇順が A < B < C になる id を用意し、それを逆順(C, B, A)で挿入する
ids = Array.new(3) { BSON::ObjectId.new }.sort
create(:output_file, setting: setting, _id: ids[2], name: 'C')
create(:output_file, setting: setting, _id: ids[1], name: 'B')
create(:output_file, setting: setting, _id: ids[0], name: 'A')
end
_id を明示的に生成して、昇順とは逆の順番で挿入する。
これで「何もしなければ C, B, A で返ってきそう」な状態が作れます。
期待するのは A, B, C なので、DB に並べ替えを頼まないと通りません。
この spec で走らせ直すと、こうなりました。
| 書き方 |
setting.output_files(通常アクセス) |
includes(:output_files) |
|---|---|---|
order: { _id: :asc } |
緑 | 赤 |
scope: -> { order_by(_id: 1) } |
緑 | 緑 |
order: は通常アクセスでは効いている。
includes だけが効いていない。
つまり order: は、includes を通る経路には届きません。
一覧を includes で取っているなら、まさにその経路です。
Mongoid のソースを開く
なぜ includes だと order: が消えるのか。8.1.10 のソースが、わりと素直に答えを持っていました。
通常アクセスの経路 - association/referenced/has_many.rb
def query_criteria(object, base)
crit = klass.criteria
crit = crit.apply_scope(scope) # scope を適用
crit = crit.where(foreign_key => object)
crit = with_polymorphic_criterion(crit, base)
crit.association = self
crit.parent_document = base
with_ordering(crit) # order を適用
end
apply_scope と with_ordering の両方を通ります。
with_ordering の中身は order があれば order_by(order) を呼ぶだけなので、orderが効きます。
includes の経路 - association/referenced/eager.rb
private def each_loaded_document_of_class(cls, keys)
return cls.none if keys.empty?
criteria = cls.criteria
criteria = criteria.apply_scope(@association.scope) # scope はここで効く
criteria = criteria.any_in(key => keys) # $in でまとめて取る
criteria.inclusions = criteria.inclusions - [@association]
criteria.each do |doc|
yield doc
end
end
apply_scope と any_in だけ。
with_ordering が一度も呼ばれていません。
ここが今回いちばん腑に落ちたところで、includes は order を無視しているとずっと思っていたんですが、そうではなくて、order を見に行く処理がそもそも呼ばれていませんでした。
そして scope: の方は、両方の経路が apply_scope を通ります。だから両方で効きます。
なので「Mongoid では scope に書くのが作法」と暗記するより、自分が使っているバージョンの eager.rb が何を呼んでいるかを見に行くほうが早いです。
どこに並び順を置くか
一応、他の選択肢も検討しました。
-
controller で取得のたびに
order- 確実に効くけれど、
output_filesを取る経路は今後も増えるはずで、開発保守の観点で重い
- 確実に効くけれど、
-
serializer で
sort_by- Ruby のメモリ上で並べるので絶対に効くけれど、serializer は複数あるので全部に入れる必要があり、追加した順に並ぶのは別にビューの都合では無い
-
model の関連定義に
scope- 一箇所で、どこから取っても並ぶ
ちなみにコストで選んでいません。
output_files は 1 設定あたり数件〜十数件なので、DB の _id インデックスで並べても Ruby で sort_by しても差はわずかです。
見ていたのは速さではなく、保守観点でした。
最終形はこれで、差分は実質 1 行です。
has_many :output_files,
scope: -> { order_by(_id: 1) },
class_name: SettingOutputFile.to_s,
dependent: :destroy
まとめ
-
通常アクセスで動いたから eager load でも動くとは限らない
-
includesを挟んだ経路は別実装です。ORM のオプションは、どの取得経路を通るかで効き方が変わる。
-
-
テストが緑なのは、テストが検証している証拠にはならない
- 一度壊して赤くなるか見る。
- 特に順番・キャッシュ・冪等性みたいに「何もしなくてもたまたま期待通りになる」性質は、意地悪に作らないと何も守ってくれません。
-
あとはソースを読むのが速い
We’re hiring!
メドレーでは一緒に働く仲間を募集しています!ご興味のある方、ぜひご連絡をお待ちしております。
最後に
MEDLEY Summer Tech Blog Relay 22日目の記事は村上さんです!
お楽しみに〜


