railsで複数ワードでの検索機能(and)とマイナス検索機能(-)を実装してみる

# はじめに
　半年前に[railsで複数ワードでの検索機能(or)とマイナス検索機能(-)を実装してみる](https://qiita.com/Orangina1050/items/ca4e5dc26e0a32ee3137 "railsで複数ワードでの検索機能(or)とマイナス検索機能(-)を実装してみる")という記事を投稿させてもらったのですが、沢山の方に見てもらい良質なフィードバックまで頂きました。本当にありがとうございます！
　そこで記事の続きという訳では無いですが、複数ワードでの検索時にorでは無くandで検索ができるようなコードをフィードバックを踏まえて書いてみました。また今回も完成品のコードを最後に置いておきます。

# 環境
Ruby 2.3.3
Rails 5.2.3
MySQL 8.0.13

# 前提として
まず前提としてこんな感じの検索フォームからコントローラの方に検索ワードを送る。

```ruby:index.html.erb
<%= form_tag('/items/search', method: :get) do %>
   <input id="page_name" name='keyword' size="30" type="text" />
<% end %>
```
その後にコントローラで入力された検索ワードに合わせてデータを引っ張ってきて表示する。

```ruby:search.html.erb
<% @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とマイナス検索)

```ruby
# キーワード分割
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に変えていく形で解説を入れながらコードを書いていきます。

# 実際に書いてみる
必要な作業は
１，送られてきたキーワードを空白で区切る（キーワード分割）
２，普通のキーワードと-(マイナス)のついたキーワードを分ける
３，普通のキーワード群でAND検索を行う
４，-(マイナス)のキーワードでNOT検索を行う

早速１から行きます。


###### 1、送られてきたキーワードを空白で区切る（キーワード分割）

検索フォームから送られてきた「水筒　A　-小さい」のようなキーワードの文字列を「"水筒","A","-小さい"」と空白で区切って複数のワードに分ける作業です。この時はまだマイナスは気にしません。

```ruby:items_controller.rb
def search
  # キーワード分割
  keywords = params[:keyword].split(/[[:blank:]]+/).select(&:present?)
end
```
params[:keyword]に検索フォームで入力された検索ワードが入っています。
それを[splitメソッド](https://ref.xaio.jp/ruby/classes/string/split "Rubyリファレンス　split (String)")で分割を行います。[:blank:]は簡単に言ったら空白やタブという意味です([この記事を参考にしました](https://qiita.com/nao58/items/bf5d017a06fc33da9e3b "キーワードを全角込みの空白で区切る"))。つまり**空白で区切って配列にする**ぜ！という意味になります。

その後に[selectメソッド](https://ref.xaio.jp/ruby/classes/array/select "Rubyリファレンス　select (Array)")で**配列から何も入っていない要素を削除**します。このメソッドの使い方はリファレンスを見るとわかりやすい。あ、present?はちょっと説明が難しいのですが何か値があるか？で真と偽を返すようです。
そもそも空白で区切ってなんで配列の要素にそんなのがあんだよ！ってなるんですが、[前回の記事のここ](https://qiita.com/Orangina1050/items/ca4e5dc26e0a32ee3137#%EF%BC%92%E5%8D%98%E8%AA%9E%E3%81%94%E3%81%A8%E3%81%AB%E6%A4%9C%E7%B4%A2%E3%82%92%E8%A1%8C%E3%81%86 "２、単語ごとに検索を行う")に理由を書いてあるので気になった方は読んでみてください。


###### ２、普通のキーワードと-(マイナス)のついたキーワードを分ける
「"水筒","A","-小さい"」のように配列になったキーワード群を普通のキーワードの配列とマイナスのキーワードの配列に分けます。

```ruby:items_controller.rb
def search
  # キーワード分割
  keywords = params[:keyword].split(/[[:blank:]]+/).select(&:present?)

  # 普通のキーワードとマイナスのキーワードを分ける
  negative_keywords, positive_keywords = 
    keywords.partition {|keyword| keyword.start_with?("-") }

end
```
` keywords` に配列で検索ワードが入っています。これを[partitionメソッド](https://ref.xaio.jp/ruby/classes/enumerable/partition "Rubyリファレンス partition (Enumerable)")で普通の検索ワードが入った配列とマイナスの検索ワードが入った配列に分けます。
partitionメソッドは配列の要素１つ１つを調べて真になったら１つめの配列型の変数に(今回の場合はnegative_keywords)、偽だったら２つめの配列型の変数に(今回の場合はpositive_keywords)に入れ直してくれます。
今回は１つ１つのキーワードに対して[start_with?("-")](https://ref.xaio.jp/ruby/classes/string/start_with "Rubyリファレンス start_with? (String)")としているので要素の先頭が「-」だったらnegative_keywordsに要素を入れるといった動きになります。

######３，普通のキーワード群でAND検索を行う
ここが一番大切なポイントです！この部分はor検索の時は以下のようになっていました。

```ruby:items_controller.rb
# 検索ワードの数だけor検索を行う
positive_keywords.each do |keyword|
  @items = @items.or(Item.where("name LIKE ?", "%#{keyword}%"))
end
```
キーワードを１つづつwhereで検索をかけてそれをorで繋げる感じ。ただ今回はandなので普通にwhere句を重ねていく。

```ruby:items_controller.rb
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なんですかね？

######４，-(マイナス)のキーワードでNOT検索を行う
ここは元の所と変える必要が無かった。**はずだった。**
とりあえず元のコードを解説。

```ruby:items_controller.rb
# -（マイナス）がついた検索ワードの数だけnot検索を行う
negative_keywords.each do |keyword|
  @items.where!("name NOT LIKE ?", "%#{keyword.delete_prefix('-')}%")
end
```
NOT LIKEでキーワードに引っかかったデータを除外している。[delete_prefixメソッド](https://techracho.bpsinc.jp/hachi8833/2017_12_15/49469 "Ruby 2.5新メソッド: #delete_prefix と #delete_suffix（翻訳）")は文字列の先頭に引数の文字があれば削除するというもの。negative_keywordsにはマイナスキーワードが ["-小さい","-コンパクト"] といった感じで入っていますが、これをこのまま「-小さい」で検索しても「小さい」にはヒットしません。
なのでdelete_prefixを使い先頭の-を削除してから検索をしている訳です。

で、問題はここから。このdelete_prefixメソッドはRubyのバージョンが**2.5**で実装されたメソッドなので自分の開発環境の**2.3**では使えない。このメソッドを教えて頂いた前回の記事ではあろうことか開発環境の項目にrailsとMysqlだけで**Rubyのバージョンを書いていない**というアホみたいな事をやらかしているという・・・すまねぇすまねぇ。

しょうがないのでdelete_prefixを使わない方向で実装する。

```ruby:items_controller.rb
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!メソッド](https://ref.xaio.jp/ruby/classes/string/slice_bang "Rubyリファレンス slice! (String)")でマイナスキーワードから先頭の-を取り除く。そして後はnot検索を行います。keyword.blank?は「""」みたいな要素が来た時の対策です。詳しくは[前回の記事のここ](https://qiita.com/Orangina1050/items/ca4e5dc26e0a32ee3137#%EF%BC%92%E3%83%9E%E3%82%A4%E3%83%8A%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%AF%E3%83%BC%E3%83%89%E9%85%8D%E5%88%97%E3%81%A7%E5%95%86%E5%93%81%E3%82%92%E6%A4%9C%E7%B4%A2 "２、マイナスキーワード配列で商品を検索")を見てみてください。

これで完成です！

# 改善点
`@items = Item`がちょっと気になる。
元々のor検索では`@items = Item.none`だったけど、and検索だと**常に検索結果が0件になる。**推測だけどnoneは空のモデルを取得するって意味らしいが、アクションレコードとしてSQLが発行されて何もヒットしなかった(だから空のモデル)扱いになってるのかな？
だとすると「"水筒","大きい"」で検索した場合条件は
1.none(ヒット無し)
2.水筒
3.大きい
になるから、orだったら1~3のどれかに合致すればデータを引っ張ってこれたけど、andだと全ての条件に合致しなければいけないから何のデータにも引っかからないnoneがあると検索結果が常に0件になるんだと思う。なので`@items = Item.all`で取り敢えず全データを入れてたけれど、all無しでも動いたので無しで動かしてる。アクションレコードに関しては勉強不足だなー。


# 完成品(コード)

```ruby:items_controller.rb
  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)は面白いメソッドが多いと思う。自分でガリガリ書かなくても良いようになっていて便利だと感じています。あと久しぶりに記事を書いたからマトモに書けてるか心配です・・・