`.in_order_of`に配列値を渡して複数レコードをグループ化してソートする
in_order_of メソッドが配列値を受け付けるようになり、複数の値を同一順位にグループ化したうえで、グループ間の順序を別のクエリで制御できるようになりました。
背景
これまで in_order_of は、各値を独立した優先順位として扱うことしかできませんでした。たとえば :published と :canceled を同じ順位グループとして扱い、その後に :archived を続けるような「グループ化ソート」は、既存のメソッドでは直接表現できませんでした。
このPRは「レコードをグループ化してから、グループ間をさらに別の順序で並べたい」というユースケースを動機としています。今回の変更により、in_order_of の値リストに配列を入れ子にするだけでグループ化ソートが実現できます。
技術的な変更
in_order_of の型キャスト処理と CASE 式の生成ロジック、フィルタ用 WHERE 句の3箇所が変更されています。
型キャスト処理では、値が配列かどうかを is_a?(Array) で判定し、配列の場合は各要素を個別にシリアライズするよう変更されました。
変更前:
values = values.map do |value|
caster.serialize(value) if caster.serializable?(value)
end
変更後:
values = values.map do |value|
if value.is_a?(Array)
value.map do |current_value|
caster.serialize(current_value) if caster.serializable?(current_value)
end
else
caster.serialize(value) if caster.serializable?(value)
end
end
CASE 式を生成する build_case_for_value_position メソッドでも、値が配列のときは column.eq の代わりに column.in を使った WHEN 節を生成するよう拡張されています。nil を含む配列の場合は column.in(value.compact).or(column.eq(nil)) に変換され、NULL値も正しく扱われます。
フィルタ用の WHERE 句生成では、values.flatten(1) を呼び出して入れ子配列を平坦化してから IN 句を構築します。これにより、グループ化された値もすべてフィルタ対象に含まれます。この変更によって生成されるSQLは次のようになります。
SELECT "posts".* FROM "posts"
WHERE "posts"."state" IN (1, 2, 3)
ORDER BY CASE
WHEN "posts"."state" IN (1, 2) THEN 1
WHEN "posts"."state" = 3 THEN 2
END ASC, "posts"."created_at" DESC
設計判断
値ごとの個別シリアライズが採用されました。PR内では Array.wrap を使う案も言及されていますが、パフォーマンス上の懸念から見送られています。また、配列型カラムへの影響(配列全体を型キャストすべきケースの有無)も課題として挙げられており、今後の議論の余地が残されています。
flatten(1) の深さを1に限定しているのも注目点です。深さを1に制限することで、入れ子配列の第1レベルだけを展開し、グループとしての意味を持つ配列構造を CASE 式の生成段階まで保持したまま、WHERE 句の構築時にのみ平坦化する設計になっています。
まとめ
型キャスト・CASE式生成・WHERE句生成の3箇所に最小限の is_a?(Array) 分岐を追加するだけで、既存のスカラー値による動作を維持しながらグループ化ソートを実現しています。in_order_of の値リストに配列を入れ子にするという直感的なAPIで、複雑なカスタムソートを SQLの CASE 式に落とし込めるようになりました。