ActiveRecord 4.2以上でunionする方法


ActiveRecordを使っていてunionしたいパターンができたので、やってみようと思ったら、思いの外ハマってしまったので情報共有したいと思います。

ActiveRecordにunionというメソッドがあることを知った私は、これでできるんじゃないの?と思って意気揚々と使ったのですが、なぜかエラーがおきました。

union = MyGroup.where(user_id: user).reorder(nil).union(
  MyGroup.limited.where(user_id: user.members).reorder(nil)
)

MyGroup.from(MyGroup.arel_table.create_table_alias(union, :my_groups).to_sql)

MyGroup.limitedは、公開範囲をActiveRecord::Enumを使っていて、限定公開をlimitedというスコープで作っています。

これを実行すると…エラーになります。

PG::ProtocolViolation: ERROR:  bind message supplies 0 parameters, but prepared statement "" requires 1
: SELECT "my_groups".* FROM ( SELECT "my_groups".* FROM "my_groups" WHERE "my_groups"."user_id" = 209 UNION SELECT "my_groups".* FROM "my_groups" WHERE "my_groups"."status" = $1 AND "my_groups"."user_id" IN (SELECT "users"."id" FROM "users" WHERE "users"."id" IN (255, 256, 209)) my_groups 

"my_groups"."status" = $1になっていて、limitedに割り当てられた数値になっていません。

unionメソッドがうまく使えなかった原因

原因は上記の通り、to_sqlを使ったタイミングでActiveRecordの検索条件に設定した値が置き換えられていなかったからです。to_sqlメソッドでSQL文を受け取る時には置換されていると思っていたのですが、unionメソッドを使うと戻り値がActiveRecord::Relationではなく、Arel::Nodes::Unionになるようです。そして、ActiveRecord::Relationのto_sqlメソッドとArelのto_sqlメソッドでは動作が違う模様です。

参考URL:to_sql in Rails 4.2 returns parameterized queries instead of full SQL statements

参考URLでリンクされているチケットのどれかに書いてありましたが、『機能の不足はバグではない』、という言葉で終わっていて、とりあえずバグとして対応されるということはなさそうです。

対応方法

ダメだったやつ(unprepared_statement)

参考URLでは、unprepared_statementを使えばいいと書いてありましたが、これを使ってもダメでした。

union_sql =  MyGroup.connection.unprepared_statement do
  MyGroup.where(user_id: user).reorder(nil).union(
    MyGroup.limited.where(user_id: user.members).reorder(nil)
  ).to_sql
end
MyGroup.from("#{union_sql} my_groups")

エラーの内容は変わらず。

うまくいったやつ(arel_tableを使う)

Arelのto_sqlの結果が云々とあったので、該当箇所のwhere文をarel_tableを使ったものに変更すればうまくいくのでは?と思い、やってみたところ、うまくいきました。

union = MyGroup.where(user_id: user).reorder(nil).union(
  # limitedスコープをarel_tableを使って表現するよう変更
  MyGroup.where(user_id: user.members).
  where(MyGroup.arel_table[:status].eq MyGroup.statuses[:limited]).reorder(nil)
)

MyGroup.from(MyGroup.arel_table.create_table_alias(union, :my_groups).to_sql)

まとめ

ArelにはArelをぶつけよう。