ArelでのOR検索ついでにごにょごにょいじってみた

at 2015-12-04 05:39 (UTC)

1つのキーワードで複数テーブルの複数カラムをあいまい検索ってよくある話ですね。
Arelを使って OR の LIKE 検索って冗長になりがちだけどそこそこパターン化出来そうだなーとつらつらとコード書いてみた。

こんなテーブルがあるとします

create_table :users do |t|
  t.string :name
  t.string :email
end

create_table :belongings do |t|
  t.integer :user_id
  t.string :company_name
  t.string :dept_name
end

1つのキーワードであらゆる属性をあいまい検索したい場合、Arelを使えばこう書けます
left join も Arel で書けますが、冗長なので eager_load で代用してます

match_key = "%#{keyword}%"
name_matches = User.arel_table[:name].matches(match_key)
email_matches = User.arel_table[:email].matches(match_key)
company_name_matches = Belonging.arel_table[:company_name].matches(match_key)
dept_name_matches = Belonging.arel_table[:dept_name].matches(match_key)

User.eager_load(:belonging).where(name_matches.or(email_matches).or(company_name_matches).or(dept_name_matches))

or のメソッドチェーンがなんかやだね。こうすれば対応カラムが増えた時 matchers の要素を増やすだけで済むぞ

match_key = "%#{keyword}%"
matchers = [User.arel_table[:name].matches(match_key),
            User.arel_table[:email].matches(match_key),
            Belonging.arel_table[:company_name].matches(match_key),
            Belonging.arel_table[:dept_name].matches(match_key)]
condition = matchers.inject { |cond, matcher| cond.or(matcher) }
User.eager_load(:belonging).where(condition)

これならシンプルに inject(&:sym) でいけるね

match_key = "%#{keyword}%"
matchers = [User.arel_table[:name].matches(match_key),
            User.arel_table[:email].matches(match_key),
            Belonging.arel_table[:company_name].matches(match_key),
            Belonging.arel_table[:dept_name].matches(match_key)]
User.eager_load(:belonging).where(matchers.inject(&:or))

matchers も定型的なので冗長だよね

class_columns_set = { User => [:name, :email], Belonging => [:company_name, :dept_name] }
matchers = class_columns_set.map do |cls, cols|
  cols.map { |col| cls.arel_table[col].matches("%#{keyword}%") }
end.flatten
User.eager_load(:belonging).where(matchers.inject(&:or))

なんかシンプルなパターンなら切り出せそう

class SimpleFinder
  def initialize(base_class, class_columns_set)
    @base_class = base_class
    @class_columns_set = class_columns_set
  end
  
  def find(keyword)
    matchers = @class_columns_set.map do |cls, cols|
      cols.map { |col| cls.arel_table[col].matches("%#{keyword}%") }
    end.flatten
    foreign_tables = @class_columns_set.keys.reject { |cls| cls == @base_class }.map(&:table_name)
    @base_class.eager_load(*foreign_tables).where(matchers.inject(&:or))
  end
end
SimpleFinder.new(User, User => [:name, :email], Belonging => [:company_name, :dept_name]).find('foobar')

実際は SimpleFinder までやると適用できるパターンが限定されるので、その手前が落とし所な気もする。 人によっては inject(&:sym) 使うぐらいが一番可読性がいいって意見もありそう。

Arelを使って OR や LIKE をするメリットは scope にして merge した時に壊れないって記述をよく見ますが、こういう風に動的に対応箇所を増やせるように持っていくのも楽というのもメリットですね。文字列で where 内を書いていたらなかなかこうはできない。あ、こんな処理を他にもたくさん書かなければいけない場合は Squeel 入れたほうがいいと思います。

全然関係ないけど、gist って編集時はインスタンス変数に色つけてくれるのに閲覧時には色つけてくれないの何でだろ

[追記] たまたま Ruby on Rails Advent Calendar 2015 が空いてたので飛び入りしました。