はじめに
今作成しているrailsアプリの商品を検索する機能が1単語でしか検索ができないので「プログラミング 初心者 Ruby」みたいな複数のワードでの検索機能と、先頭に-(マイナス)をつけたらそのキーワードを含む商品が検索結果から除外されるマイナス検索を実装してみた。
・ちょっと正規表現が入ってきます
・記事に載せているのはコントローラでの機能の実装の部分に絞っています
記事が長くなってしまったので最後に完成品のコードだけの項目を作成しました。
環境
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テーブルが存在し、
def search
redirect_to root_path if params[:keyword] == "" # キーワードが入力されていないとトップページに飛ぶ
@items = Item.where('name LIKE(?)', "%#{params[:keyword]}%") #部分一致で検索
end
こういったitemテーブルのnameカラムに対して検索を行うsearchアクションが存在した。
params[:keyword]
には検索フォームに入力された文字列が入っていてそれを%
で囲むと部分一致でのあいまい検索ができるようになります。
入力されたキーワードがレコードのnameカラムに部分一致すればそのレコードを@items
変数に代入して検索結果画面で表示をするという流れになっています。
複数ワードでの検索機能
流れとしては
1、送られてきたキーワードを空白で区切る
2、単語ごとに検索を行う
1に関しては検索フォームから送られてきた「Java C# Ruby」のようなキーワードを「"Java","C#","Ruby"」のように空白で区切って複数のキーワードに分ける作業です。
2は1で区切った複数のキーワードでDBに検索をかけます。今回は素直にeach文で実装を行いました。
1、送られてきたキーワードを空白で区切る
split
メソッドを利用してキーワードを分割します。このメソッドは文字列に対して利用できるメソッドで、引数で文字列を分割し配列にして返します。
keyword = "Java C# ruby" #1単語の文字列
p keyword.split(" ")
# => ["Java", "C#", "ruby"] 空白で区切って3つの単語に分割した配列
今回は空白で区切って配列を作成しようとしてたけど単にsplit(" ")
だと全角スペースに反応してくれない。ムムム・・・と思っていたらドンピシャの記事があった。なるほど、[:blank:]
で空白文字に対応できるのか。正規表現は苦手だけど試してみよう。
def search
redirect_to root_path if params[:keyword] == ""
split_keywords = params[:keyword].split(/[[:blank:]]+/) # 空白で分割
@items = Item.where('name LIKE(?)', "%#{params[:keyword]}%")
end
後ろに+をつけて連続した空白にも対応できるようにした。これでキーワードを分割できるようになった。
2、単語ごとに検索を行う
これはそんなに難しくない。区切った文字ごとに検索をかければ良い。
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に分割されたキーワードが入っていてそれを1つずつeachで取り出して検索をかけている。next if keyword == ""
の所は先頭の空白対策で記述している。
実はこれを記述しないと先頭に空白を入れて検索したとき全てのレコードにヒットしてしまう。
理屈としては「 Ruby」のように先頭に区切り文字が来た時** ""という何も無い要素が配列に追加される。**
keyword = " ruby" #先頭に空白がある
p keyword.split(" ")
# => ["", "ruby"] 空白で区切ると何もない要素が誕生する
つまり「 Ruby」という文字列を空白で区切ると、
「 Ruby」→「/Ruby」→「」「Ruby」という風に2単語に分割されてしまうのだ。
そして**""というワードは全ての文字列にヒットする。**なので""
の際は検索をしない処理が必要になってくる。
そして今のままだともう一つ問題が。レコードが重複してしまう可能性がある。
例えば「大きい水筒」という名前の商品があったとする。
それに対して「水筒 大きい」というキーワードで検索をかけたとする。
すると「水筒」「大きい」と2つのワードに分けられてそれぞれのワードで検索がかけられるが両方のワードでヒットしてしまって配列に複数回代入されてしまう。
なのでuniq!
メソッドを使い配列から重複した要素を取り除く。
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、送られてきた文字列を分割した配列から先頭にマイナスがついたキーワードを抜き出しマイナスキーワード配列を作成
2、マイナスキーワード配列で商品を検索
3、ヒットした商品からマイナスキーワードでヒットした商品を削除
です。ただかなり大雑把に分けているのでこの通りにはいかないかも・・・
1、配列から先頭にマイナスがついたキーワードを抜き出しマイナスキーワード配列を作成
select
メソッドを利用して先頭に-(マイナス)がついた文字を取り出します。
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!
メソッドを利用します。
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
2、マイナスキーワード配列で商品を検索
複数ワードでの検索の時と同じことを行う。
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
ちなみにここでも""
を飛ばす処理をしているのは「-」だけで検索をされるとやはり全ての商品にヒットしてしまうからです。
「"水筒", "大きい", "-アルミ", "-"」からマイナスキーワードを抜き出すと
「"-アルミ", "-"」となるがここから-を削除すると
「"アルミ", ""」となりまたもや""
が出現します。また君かぁ。
こいつがマイナスワードにいると全ての商品にヒットして「3、ヒットした商品からマイナスキーワードでヒットした商品を削除」の時に全ての商品を消してしまうので対策をしておかないといけない。
3、ヒットした商品からマイナスキーワードでヒットした商品を削除
delete
メソッドを利用してヒットした商品が入っている@item
配列からマイナスキーワードでヒットした商品を削除する。
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に接続する回数が減って良いような気がします。自分には思いつきませんでした・・・(小声)
コード(完成品)
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
に検索結果が入っている**ということだけ覚えておくと大丈夫です。
ちなみにルーティングはこうでした。
root'items#index'
resources :items do
collection do
get 'search'
end
end
おわりに
時間が無かったから手動で実装したけどこれ絶対gemで実装した方が早いと思いました。ransackとかでできるんじゃないかなぁ?
参考
https://ref.xaio.jp/ruby/classes/array
https://qiita.com/nao58/items/bf5d017a06fc33da9e3b
http://www.kt.rim.or.jp/~kbk/regex/regex.html