はじめに
半年前にrailsで複数ワードでの検索機能(or)とマイナス検索機能(-)を実装してみるという記事を投稿させてもらったのですが、沢山の方に見てもらい良質なフィードバックまで頂きました。本当にありがとうございます!
そこで記事の続きという訳では無いですが、複数ワードでの検索時にorでは無くandで検索ができるようなコードをフィードバックを踏まえて書いてみました。また今回も完成品のコードを最後に置いておきます。
環境
Ruby 2.3.3
Rails 5.2.3
MySQL 8.0.13
前提として
まず前提としてこんな感じの検索フォームからコントローラの方に検索ワードを送る。
<%= form_tag('/items/search', method: :get) do %>
<input id="page_name" name='keyword' size="30" type="text" />
<% end %>
その後にコントローラで入力された検索ワードに合わせてデータを引っ張ってきて表示する。
<% @items.each do |item| %>
<%= item.name %>
<% end %>
ちなみにDBにはこんなデータが入っているとします。
mysql> select name from items;
+------------------+
| name |
+------------------+
| 水筒A |
| 水筒B |
| 水筒C |
| 大きい水筒 |
| 大きい水筒A |
| 小さい水筒 |
| 小さい水筒A |
+------------------+
7 rows in set (0.00 sec)
前提のコード(orとマイナス検索)
# キーワード分割
keywords = params[:keyword].split(/[[:blank:]]+/).select(&:present?)
# 普通のキーワードとマイナスのキーワードを分ける
negative_keywords, positive_keywords =
keywords.partition {|keyword| keyword.start_with?("-") }
# 空のモデルオブジェクト作成(何も入っていない空配列のようなもの)
@items = Item.none
# 検索ワードの数だけor検索を行う
positive_keywords.each do |keyword|
@items = @items.or(Item.where("name LIKE ?", "%#{keyword}%"))
end
# -(マイナス)がついた検索ワードの数だけnot検索を行う
negative_keywords.each do |keyword|
@items.where!("name NOT LIKE ?", "%#{keyword.delete_prefix('-')}%")
end
前回の記事でkg8mさんから教えて頂いた複数ワードでの検索機能(or)とマイナス検索機能(-)のコードです。短くて素敵。これをorからandに変えていく形で解説を入れながらコードを書いていきます。
実際に書いてみる
必要な作業は
1,送られてきたキーワードを空白で区切る(キーワード分割)
2,普通のキーワードと-(マイナス)のついたキーワードを分ける
3,普通のキーワード群でAND検索を行う
4,-(マイナス)のキーワードでNOT検索を行う
早速1から行きます。
1、送られてきたキーワードを空白で区切る(キーワード分割)
検索フォームから送られてきた「水筒 A -小さい」のようなキーワードの文字列を「"水筒","A","-小さい"」と空白で区切って複数のワードに分ける作業です。この時はまだマイナスは気にしません。
def search
# キーワード分割
keywords = params[:keyword].split(/[[:blank:]]+/).select(&:present?)
end
params[:keyword]に検索フォームで入力された検索ワードが入っています。
それをsplitメソッドで分割を行います。[:blank:]は簡単に言ったら空白やタブという意味です(この記事を参考にしました)。つまり空白で区切って配列にするぜ!という意味になります。
その後にselectメソッドで配列から何も入っていない要素を削除します。このメソッドの使い方はリファレンスを見るとわかりやすい。あ、present?はちょっと説明が難しいのですが何か値があるか?で真と偽を返すようです。
そもそも空白で区切ってなんで配列の要素にそんなのがあんだよ!ってなるんですが、前回の記事のここに理由を書いてあるので気になった方は読んでみてください。
2、普通のキーワードと-(マイナス)のついたキーワードを分ける
「"水筒","A","-小さい"」のように配列になったキーワード群を普通のキーワードの配列とマイナスのキーワードの配列に分けます。
def search
# キーワード分割
keywords = params[:keyword].split(/[[:blank:]]+/).select(&:present?)
# 普通のキーワードとマイナスのキーワードを分ける
negative_keywords, positive_keywords =
keywords.partition {|keyword| keyword.start_with?("-") }
end
keywords
に配列で検索ワードが入っています。これをpartitionメソッドで普通の検索ワードが入った配列とマイナスの検索ワードが入った配列に分けます。
partitionメソッドは配列の要素1つ1つを調べて真になったら1つめの配列型の変数に(今回の場合はnegative_keywords)、偽だったら2つめの配列型の変数に(今回の場合はpositive_keywords)に入れ直してくれます。
今回は1つ1つのキーワードに対してstart_with?("-")としているので要素の先頭が「-」だったらnegative_keywordsに要素を入れるといった動きになります。
######3,普通のキーワード群でAND検索を行う
ここが一番大切なポイントです!この部分はor検索の時は以下のようになっていました。
# 検索ワードの数だけor検索を行う
positive_keywords.each do |keyword|
@items = @items.or(Item.where("name LIKE ?", "%#{keyword}%"))
end
キーワードを1つづつwhereで検索をかけてそれをorで繋げる感じ。ただ今回はandなので普通にwhere句を重ねていく。
def search
# キーワード分割
keywords = params[:keyword].split(/[[:blank:]]+/).select(&:present?)
# 普通のキーワードとマイナスのキーワードを分ける
negative_keywords, positive_keywords =
keywords.partition {|keyword| keyword.start_with?("-") }
# Itemモデルオブジェクト作成
@items = Item
# 検索ワードの数だけand検索を行う
positive_keywords.each do |keyword|
@items = @items.where("name LIKE ?", "%#{keyword}%")
end
end
これでいけるはず。
ただし@items = Item
はどうなんでしょうかね?これOKなんですかね?
######4,-(マイナス)のキーワードでNOT検索を行う
ここは元の所と変える必要が無かった。はずだった。
とりあえず元のコードを解説。
# -(マイナス)がついた検索ワードの数だけnot検索を行う
negative_keywords.each do |keyword|
@items.where!("name NOT LIKE ?", "%#{keyword.delete_prefix('-')}%")
end
NOT LIKEでキーワードに引っかかったデータを除外している。delete_prefixメソッドは文字列の先頭に引数の文字があれば削除するというもの。negative_keywordsにはマイナスキーワードが ["-小さい","-コンパクト"] といった感じで入っていますが、これをこのまま「-小さい」で検索しても「小さい」にはヒットしません。
なのでdelete_prefixを使い先頭の-を削除してから検索をしている訳です。
で、問題はここから。このdelete_prefixメソッドはRubyのバージョンが2.5で実装されたメソッドなので自分の開発環境の2.3では使えない。このメソッドを教えて頂いた前回の記事ではあろうことか開発環境の項目にrailsとMysqlだけでRubyのバージョンを書いていないというアホみたいな事をやらかしているという・・・すまねぇすまねぇ。
しょうがないのでdelete_prefixを使わない方向で実装する。
def search
# キーワード分割
keywords = params[:keyword].split(/[[:blank:]]+/).select(&:present?)
# 普通のキーワードとマイナスのキーワードを分ける
negative_keywords, positive_keywords =
keywords.partition {|keyword| keyword.start_with?("-") }
# Itemモデルオブジェクト作成
@items = Item
# 検索ワードの数だけand検索を行う
positive_keywords.each do |keyword|
@items = @items.where("name LIKE ?", "%#{keyword}%")
end
# マイナスキーワードの先頭から-を取り除く
negative_keywords.each {|word| word.slice!(/^-/) }
# マイナスキーワードでnot検索
negative_keywords.each do |keyword|
next if keyword.blank?
@items = @items.where.not("name LIKE ?", "%#{keyword}%")
end
end
まずslice!メソッドでマイナスキーワードから先頭の-を取り除く。そして後はnot検索を行います。keyword.blank?は「""」みたいな要素が来た時の対策です。詳しくは前回の記事のここを見てみてください。
これで完成です!
改善点
@items = Item
がちょっと気になる。
元々のor検索では@items = Item.none
だったけど、and検索だと**常に検索結果が0件になる。**推測だけどnoneは空のモデルを取得するって意味らしいが、アクションレコードとしてSQLが発行されて何もヒットしなかった(だから空のモデル)扱いになってるのかな?
だとすると「"水筒","大きい"」で検索した場合条件は
1.none(ヒット無し)
2.水筒
3.大きい
になるから、orだったら1~3のどれかに合致すればデータを引っ張ってこれたけど、andだと全ての条件に合致しなければいけないから何のデータにも引っかからないnoneがあると検索結果が常に0件になるんだと思う。なので@items = Item.all
で取り敢えず全データを入れてたけれど、all無しでも動いたので無しで動かしてる。アクションレコードに関しては勉強不足だなー。
完成品(コード)
def search
keywords = params[:keyword].split(/[[:blank:]]+/).select(&:present?)
negative_keywords, positive_keywords =
keywords.partition {|keyword| keyword.start_with?("-") }
@items = Item
positive_keywords.each do |keyword|
@items = @items.where("name LIKE ?", "%#{keyword}%")
end
negative_keywords.each {|word| word.slice!(/^-/) }
negative_keywords.each do |keyword|
next if keyword.blank?
@items = @items.where.not("name LIKE ?", "%#{keyword}%")
end
end
あと今回のコードは検索フォームにキーワードが入力されていなかった場合の処理を書いていないので必要に応じて付け足してください。
おわりに
Ruby(Rails)は面白いメソッドが多いと思う。自分でガリガリ書かなくても良いようになっていて便利だと感じています。あと久しぶりに記事を書いたからマトモに書けてるか心配です・・・