ActiveRecord で join と or と and が入り混じった場合

at 2017-03-17 07:25 (UTC)

create_table :members do |t|
  t.string :name
  t.string :kana
  t.integer :age
end

create_table :emails do |t|
  t.references :member
  t.string :address
end

細かいオプションはさしおいてこんなテーブルがあるとして

SELECT *
FROM members m
JOIN emails e
ON e.member_id = m.id
WHERE m.age IN (21, 22, 23)
AND (m.name = 'foo' OR m.kana = 'foo' OR e.address = 'foo')

メンバー検索として結果的にこんな SQL を発行したい。

まずは emails を考えずに members だけに対象を絞る。

Member.where(age: [21,22,23])
      .or(Member.where(name: 'foo'))
      .or(Member.where(kana: 'foo'))
#=> SELECT "members".* FROM "members" WHERE (("members"."age" IN (21, 22, 23) OR "members"."name" = $1) OR "members"."kana" = $2)  [["name", "foo"], ["kana", "foo"]]
-- 実質のクエリ
SELECT members.*
FROM members
WHERE (
  (
    members.age IN (21, 22, 23) OR
    members.name = 'foo'
  )
  OR members.kana = 'foo'
)

全ての条件が OR でつながってしまい意味合いが違ってしまう。
正しくはこうする。

Member.where(name: 'foo')
      .or(Member.where(kana: 'foo'))
      .where(age: [21,22,23])
#=> SELECT "members".* FROM "members" WHERE ("members"."name" = $1 OR "members"."kana" = $2) AND "members"."age" IN (21, 22, 23)  [["name", "foo"], ["kana", "foo"]]
-- 実質のクエリ
SELECT members.*
FROM members
WHERE (members.name = $1 OR members.kana = $2)
AND members.age IN (21, 22, 23)

さてここに emails を絡めていきたい。

Member.joins(:emails)
      .where(name: 'foo')
      .or(Member.where(kana: 'foo'))
      .or(Email.where(address: 'foo'))
      .where(age: [21,22,23])
#=> ArgumentError: Relation passed to #or must be structurally compatible. Incompatible values: [:joins]

or に渡すのは構造的に同じものを渡せというエラーになった。

scope = Member.joins(:emails)
scope.where(name: 'foo')
     .or(scope.where(kana: 'foo'))
     .or(scope.where(emails: { address: 'foo' }))
     .where(age: [21,22,23])
#=> ArgumentError: Relation passed to #or must be structurally compatible. Incompatible values: [:references]

これでもダメはツライ。

scope = Member.joins(:emails)
scope.where(name: 'foo')
     .or(scope.where(kana: 'foo'))
     .or(scope.where(id: Email.where(address: 'foo')))
     .where(age: [21,22,23])
#=> SELECT "members".* FROM "members" INNER JOIN "emails" ON "emails"."member_id" = "members"."id" WHERE (("members"."name" = $1 OR "members"."kana" = $2) OR "members"."id"  IN (SELECT "emails"."id" FROM "emails" WHERE "emails"."address" = $3)) AND "members"."age" IN (21, 22, 23)  [["name", "foo"], ["kana", "foo"], ["address", "foo"]]
-- 実質のクエリ
SELECT members.* 
FROM members 
INNER JOIN emails 
ON emails.member_id = members.id 
WHERE (
  (
    members.name = 'foo' OR
    members.kana = 'foo'
  ) OR
  members.id IN (
    SELECT emails.id
    FROM emails
    WHERE emails.address = 'foo'
  )
)
AND members.age IN (21, 22, 23)

結局これで妥協した。

ちなみに Arel を使うとこうなる。

age = Member.arel_table[:age].in([21, 22, 23])
name = Member.arel_table[:name].eq('foo')
kana = Member.arel_table[:kana].eq('foo')
mail = Email.arel_table[:address].eq('foo')
Member.joins(:emails).where(age.and(name.or(kana).or(mail)))
#=> SELECT "members".* FROM "members" INNER JOIN "emails" ON "emails"."member_id" = "members"."id" WHERE ("members"."generation" IN (21, 22, 23) AND (("members"."given_name" = 'foo' OR  "members"."given_kana" = 'foo') OR "emails"."address" = 'foo'))
-- 実質のクエリ
SELECT members.* 
FROM members 
INNER JOIN emails 
ON emails.member_id = members.id 
WHERE (
  members.generation IN (21, 22, 23) AND
  (
    (
      members.given_name = 'foo' OR 
      members.given_kana = 'foo'
    ) OR 
    emails.address = 'foo'
  )
)

or はできることの制限が厳しく発行したいSQLからどのように書けばよいかが直感的ではない。
Arel は内部ライブラリなのであまり使わないほうがよいことは承知しているがカッコを使った制御が直感的に行える。
さて、あなたはどちらを選びますか?