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


# はじめに
今作成しているrailsアプリの商品を検索する機能が１単語でしか検索ができないので「プログラミング　初心者　Ruby」みたいな複数のワードでの検索機能と、先頭に-(マイナス)をつけたらそのキーワードを含む商品が検索結果から除外されるマイナス検索を実装してみた。

・ちょっと正規表現が入ってきます
・記事に載せているのはコントローラでの機能の実装の部分に絞っています

記事が長くなってしまったので最後に完成品のコードだけの項目を作成しました。

追記：[and検索バージョンの記事を書きました。](https://qiita.com/Orangina1050/items/c53919864b5313ab9672 "railsで複数ワードでの検索機能(end)とマイナス検索機能(-)を実装してみる")

# 環境
Rails 5.1.6
MySQL 5.6.4


# 1単語での検索機能
最初の状態。

```
mysql> select id, name, price, company  from items limit 2;
+----+---------+-------+------------+
| id | name    | price | company    |
+----+---------+-------+------------+
|  1 | 水筒A   |   100 | A工業       |
|  2 | 水筒B   |   200 | B製作所     |
+----+---------+-------+------------+
2 rows in set (0.00 sec)
```

こういった商品のデータが入っているitemsテーブルが存在し、


```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == "" # キーワードが入力されていないとトップページに飛ぶ
  @items = Item.where('name LIKE(?)', "%#{params[:keyword]}%") #部分一致で検索
end
```

こういったitemテーブルのnameカラムに対して検索を行うsearchアクションが存在した。
`params[:keyword]`には検索フォームに入力された文字列が入っていてそれを`%`で囲むと部分一致でのあいまい検索ができるようになります。
入力されたキーワードがレコードのnameカラムに部分一致すればそのレコードを`@items`変数に代入して検索結果画面で表示をするという流れになっています。


# 複数ワードでの検索機能
流れとしては

１、送られてきたキーワードを空白で区切る
２、単語ごとに検索を行う

１に関しては検索フォームから送られてきた「Java　C#　Ruby」のようなキーワードを「"Java","C#","Ruby"」のように空白で区切って複数のキーワードに分ける作業です。
２は１で区切った複数のキーワードでDBに検索をかけます。今回は素直にeach文で実装を行いました。

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

`split`メソッドを利用してキーワードを分割します。このメソッドは文字列に対して利用できるメソッドで、**引数で文字列を分割し配列にして返します。**

```ruby

keyword = "Java C# ruby" #１単語の文字列
p keyword.split(" ")
# => ["Java", "C#", "ruby"] 空白で区切って3つの単語に分割した配列
```

今回は空白で区切って配列を作成しようとしてたけど単に`split(" ")`だと全角スペースに反応してくれない。ムムム・・・と思っていたら[ドンピシャの記事があった。](https://qiita.com/nao58/items/bf5d017a06fc33da9e3b "キーワードを全角込みの空白で区切る")なるほど、`[:blank:]`で空白文字に対応できるのか。正規表現は苦手だけど試してみよう。

```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == ""

  split_keywords = params[:keyword].split(/[[:blank:]]+/) # 空白で分割
  @items = Item.where('name LIKE(?)', "%#{params[:keyword]}%")
end
```
後ろに＋をつけて連続した空白にも対応できるようにした。これでキーワードを分割できるようになった。

###### ２、単語ごとに検索を行う

これはそんなに難しくない。区切った文字ごとに検索をかければ良い。

```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == ""

  split_keyword = params[:keyword].split(/[[:blank:]]+/)

  @items = [] 
  split_keyword.each do |keyword|  # 分割したキーワードごとに検索
    next if keyword == "" 
    @items += Item.where('name LIKE(?)', "%#{keyword}%") # 部分一致で検索 
  end 
end
```
split_keywordに分割されたキーワードが入っていてそれを１つずつeachで取り出して検索をかけている。`next if keyword == "" `の所は先頭の空白対策で記述している。
実はこれを記述しないと先頭に空白を入れて検索したとき**全てのレコードにヒットしてしまう。**

理屈としては「　Ruby」のように先頭に区切り文字が来た時**　""という何も無い要素が配列に追加される。**

```ruby
keyword = " ruby" #先頭に空白がある
p keyword.split(" ")
# => ["", "ruby"] 空白で区切ると何もない要素が誕生する
```
つまり「 Ruby」という文字列を空白で区切ると、
「 Ruby」→「/Ruby」→「」「Ruby」という風に**２単語に分割されてしまうのだ。**
そして**""というワードは全ての文字列にヒットする。**なので`""`の際は検索をしない処理が必要になってくる。

そして今のままだともう一つ問題が。**レコードが重複してしまう可能性がある。**
例えば「大きい水筒」という名前の商品があったとする。
それに対して「水筒　大きい」というキーワードで検索をかけたとする。
すると「水筒」「大きい」と２つのワードに分けられてそれぞれのワードで検索がかけられるが**両方のワードでヒットしてしまって配列に複数回代入されてしまう。**
なので`uniq!`メソッドを使い配列から重複した要素を取り除く。

```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == ""

  split_keyword = params[:keyword].split(/[[:blank:]]+/)

  @items = [] 
  split_keyword.each do |keyword|
    next if keyword == "" 
    @items += Item.where('name LIKE(?)', "%#{keyword}%")
  end 
  @items.uniq! #重複した商品を削除する
end
```


これで複数ワードでの検索機能は完成です。

# マイナス検索機能
これ考えてたよりもめんどくさかった。最初考えたのと違う動きになっていて少なくとも最適解じゃないと思う。

実装の流れとしては

１、送られてきた文字列を分割した配列から先頭にマイナスがついたキーワードを抜き出しマイナスキーワード配列を作成
２、マイナスキーワード配列で商品を検索
３、ヒットした商品からマイナスキーワードでヒットした商品を削除

です。ただかなり大雑把に分けているのでこの通りにはいかないかも・・・

###### 1、配列から先頭にマイナスがついたキーワードを抜き出しマイナスキーワード配列を作成

`select`メソッドを利用して先頭に-(マイナス)がついた文字を取り出します。


```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == ""

  split_keyword = params[:keyword].split(/[[:blank:]]+/) # 空白で分割
  minus_keyword = split_keyword.select {|word| word.match(/^-/) } # 先頭に-がついたキーワードを抜きだす

  @items = [] 
  split_keyword.each do |keyword|
    next if keyword == "" 
    @items += Item.where('name LIKE(?)', "%#{keyword}%")
  end 
  @items.uniq!
end
```

`minus_keyword`には先頭に-がついたキーワードが配列になって入っています。これで検索結果から取り除く商品を探すのですが、`slice!`メソッドを利用しこのワードで検索する前に先頭の-(マイナス)を削除します。「-大きい」のワードで検索しても「大きい」にはヒットしないので。
あと元の普通の検索用のキーワードが入っている配列からマイナスキーワードを削除します。削除には`reject!`メソッドを利用します。

```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == ""

  split_keyword = params[:keyword].split(/[[:blank:]]+/)
  minus_keyword = split_keyword.select {|word| word.match(/^-/) }

  split_keyword.reject! {|word| word.match(/^-/) } # 先頭に-がついたキーワードを配列から削除 
  minus_keyword.each {|word| word.slice!(/^-/) } # マイナスキーワードの先頭の-を削除する 

  @items = [] 
  split_keyword.each do |keyword|
    next if keyword == "" 
    @items += Item.where('name LIKE(?)', "%#{keyword}%")
  end 
  @items.uniq!
end
```

###### ２、マイナスキーワード配列で商品を検索
複数ワードでの検索の時と同じことを行う。

```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == ""

  split_keyword = params[:keyword].split(/[[:blank:]]+/)
  minus_keyword = split_keyword.select {|word| word.match(/^-/) }

  split_keyword.reject! {|word| word.match(/^-/) }
  minus_keyword.each {|word| word.slice!(/^-/) }

  @items = [] 
  split_keyword.each do |keyword|
    next if keyword == "" 
    @items += Item.where('name LIKE(?)', "%#{keyword}%")  
  end 
  @items.uniq!

  minus_items = [] 
  minus_keyword.each do |keyword| # マイナスキーワードで検索 
    next if keyword == "" 
    minus_items += Item.where('name LIKE(?)', "%#{keyword}%")# 部分一致で検索
  end 
end
```

ちなみにここでも`""`を飛ばす処理をしているのは「-」だけで検索をされるとやはり全ての商品にヒットしてしまうからです。

「"水筒", "大きい", "-アルミ", "-"」からマイナスキーワードを抜き出すと
「"-アルミ", "-"」となるがここから-を削除すると
「"アルミ", ""」となりまたもや`""`が出現します。また君かぁ。

こいつがマイナスワードにいると全ての商品にヒットして「３、ヒットした商品からマイナスキーワードでヒットした商品を削除」の時に全ての商品を消してしまうので対策をしておかないといけない。

###### ３、ヒットした商品からマイナスキーワードでヒットした商品を削除

`delete`メソッドを利用してヒットした商品が入っている`@item`配列からマイナスキーワードでヒットした商品を削除する。


```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == ""

  split_keyword = params[:keyword].split(/[[:blank:]]+/)
  minus_keyword = split_keyword.select {|word| word.match(/^-/) }

  split_keyword.reject! {|word| word.match(/^-/) }
  minus_keyword.each {|word| word.slice!(/^-/) }

  @items = [] 
  split_keyword.each do |keyword|
    next if keyword == "" 
    @items += Item.where('name LIKE(?)', "%#{keyword}%")
  end 
  @items.uniq!

  minus_items = [] 
  minus_keyword.each do |keyword|
    next if keyword == "" 
    minus_items += Item.where('name LIKE(?)', "%#{keyword}%")
  end 

  minus_items.each do |minus_item| 
    @items.delete(minus_item) #ヒットした商品からマイナスキーワードでヒットした商品を削除 
  end
end
```

これでやっと完成です、お疲れさまでした！

# 改善点
最初の予定ではマイナスキーワードでDBに接続する所は無くヒットした商品の名前からマイナスキーワードが入っているか調べて削除する手筈でした。多分そっちの方がDBに接続する回数が減って良いような気がします。自分には思いつきませんでした・・・(小声)

# コード(完成品)

```ruby:items_controller.rb
def search
  redirect_to root_path if params[:keyword] == ""

  split_keyword = params[:keyword].split(/[[:blank:]]+/)
  minus_keyword = split_keyword.select {|word| word.match(/^-/) }

  split_keyword.reject! {|word| word.match(/^-/) }
  minus_keyword.each {|word| word.slice!(/^-/) }

  @items = [] 
  split_keyword.each do |keyword|
    next if keyword == "" 
    @items += Item.where('name LIKE(?)', "%#{keyword}%")
  end 
  @items.uniq!

  minus_items = [] 
  minus_keyword.each do |keyword|
    next if keyword == "" 
    minus_items += Item.where('name LIKE(?)', "%#{keyword}%")
  end 

  minus_items.each do |minus_item| 
    @items.delete(minus_item)
  end
end
```
凄いごちゃごちゃ書いているんですがぶっちゃけ**`@items`に検索結果が入っている**ということだけ覚えておくと大丈夫です。

ちなみにルーティングはこうでした。

```ruby:routes.rb 
root'items#index'

resources :items do
  collection do
    get 'search'
  end
end
```



# おわりに
時間が無かったから手動で実装したけどこれ絶対gemで実装した方が早いと思いました。ransackとかでできるんじゃないかなぁ？

追記：[and検索バージョンの記事を書きました。](https://qiita.com/Orangina1050/items/c53919864b5313ab9672 "railsで複数ワードでの検索機能(end)とマイナス検索機能(-)を実装してみる")

# 参考
https://ref.xaio.jp/ruby/classes/array
https://qiita.com/nao58/items/bf5d017a06fc33da9e3b
http://www.kt.rim.or.jp/~kbk/regex/regex.html