Ruby on Rails チュートリアルで30歳までに人生を変える(番外編:検索機能の拡張)
こんにちは。opiyoです。
今回は、番外編:検索機能の拡張をやっていきます。
ユーザーとマイクロポストをあいまい検索できるような機能を各画面に追加します。
ではでは、早速行ってみましょう。
railsチュートリアル検索機能の拡張で学んだこと
ユーザー一覧に名前をあいまい検索できる
# app/views/users/index.html.erb <% provide(:title, 'All users') %> <h1>All users</h1> <%= will_paginate %> <%= form_tag users_path, method: :get do %> <p> <%= text_field_tag :search %> <%= submit_tag "検索" %> </p> <% end %> <ul class="users"> <%= render @users %> </ul> <%= will_paginate %>
# app/controllers/users_controller.rb def index @user_form = User.new @users = User.paginate(page: params[:page]).search(params[:search]) end
# app/models/user.rb def self.search(search) if search where('name LIKE ?', "%#{sanitize_sql_like(search)}%") # Likeインジェクション対策をしてみた else all end end
ユーザー詳細のマイクロポストのコンテンツをあいまい検索できる
【検索前】
【検索後】
# app/views/users/show.html.erb <% provide(:title, @user.name) %> <div class="row"> <aside class="col-md-4"> <section class="user_info"> <h1> <%= gravatar_for @user %> <%= @user.name %> </h1> </section> <section class="stats"> <%= render 'shared/stats' %> </section> </aside> <div class="col-md-8"> <%= render 'follow_form' if logged_in? %> <% if @microposts.any? %> <h3>MicroPosts (<%= @microposts.count %>)</h3> # 検索結果のマイクロポストの数を表示させる <%= form_tag user_path, method: :get do %> # ----- ここから追加した検索フォーム ----- <p> <%= text_field_tag :search %> <%= submit_tag "検索" %> </p> <% end %> # ----- ここまで ----- <ol class="microposts"> <%= render @microposts %> </ol> <%= will_paginate @microposts %> <% end %> </div> </div>
# app/controllers/users_controller.rb def show @user = User.find(params[:id]) @microposts = @user.microposts.paginate(page: params[:page]).where('content LIKE ?', "%#{params[:search]}%") # 何も入力せず検索しても「""」で渡ってくるので、エラーにはならない # @microposts = @user.microposts.paginate(page: params[:page]).search(params[:search]) # こう書いてモデル側でSQL組み立てたいのだが... end
トップページのマイクロポストのコンテンツをあいまい検索できる
# app/views/static_pages/home.html.erb <% if logged_in? %> <div class="row"> <aside class="col-md-4"> <section class="user_info"> <%= render 'shared/user_info' %> </section> <section class="stats"> <%= render 'shared/stats' %> </section> <section class="micropost_form"> <%= render 'shared/micropost_form' %> </section> </aside> <div class="col-md-8"> <h3>Micropost Feed</h3> <%= form_tag root_path, method: :get do %> # ----- ここからformの追加 ----- <p> <%= text_field_tag :search %> <%= submit_tag "検索" %> </p> <% end %> # ----- ここまで ----- <%= render 'shared/feed' %> </div> </div> <% else %> <% provide(:title, "Home") %> <div class="center jumbotron"> <h1>Welcome to the Sample App</h1> <h2> This is the home page for the <a href="http://railstutorial.jp/">Ruby on Rails Tutorial</a> sample application. </h2> <%= link_to "Sign up now!", signup_path, class: "btn btn-lg btn-primary" %> </div> <%= link_to image_tag("rails.png", alt: "Rails logo"), "https://rubyonrails.org/" %> <% end %>
# app/controllers/static_pages_controller.rb def home if logged_in? @micropost = current_user.microposts.build if logged_in? @feed_items = current_user.feed.paginate(page: params[:page]).where('content LIKE ?', "%#{params[:search]}%") # 何も入力せず検索しても「""」で渡ってくるので、エラーにはならない end end
railsチュートリアル検索機能の拡張で学んだこと
form_forは難しい
# index.html.erb <%= form_for @user_form, url: users_path, method: :get do |f| %> <%= f.text_field :name %> <%= f.submit "Serach", class: "btn btn-primary" %> <% end %>
オブジェクトを指定するときはコントローラー側で必ずnew
すべし。検索結果があるから大丈夫って思ってたんだけど何故かエラーになる。
だからとりあえず、form_for
で使うオブジェクトは検索結果を格納したオブジェクトとは別にnew
したオブジェクトを作って指定した。
あとは、f.text_field :name
で渡すシンボルの部分がform_for
で使ったモデルにあるカラム名と同じじゃないとエラーになる。
paginateの後にwhereしてもいける!
@user.microposts.where("content LIKE ?", "%#{params[:search]}%").paginate(page: params[:page]) @user.microposts.paginate(page: params[:page]).where("content LIKE ?", "%#{params[:search]}%") # whereを後ろに持ってこれる。
これ見てもらった方が分かりやすいと思うのだけど、検索結果にはpaginate
メソッドを使ってページネーションができるようにしてます。
だから、paginate
メソッドの前にしかwhere
できないと思ってたのだけどそんなことなかった。
あいまい検索
where("検索したい項目 LIKE ?", "%#{sanitize_sql_like(hogehoge)}%")
基本的にはwhere
の第一引数に文字列を渡して、第二引数に?
に入る値を渡してやればOK
SQL文の組み立てはモデルで
コントローラーでやる場合
# users_controller.rb def index @user_form = User.new @users = User.where("name like ?", "%#{params[:user][:name]}%").paginate(page: params[:page]) # かっこ悪い。 end
モデルでやる場合
# users_controller.rb def index @user_form = User.new @users = User.paginate(page: params[:page]).search(params[:search]) # Userモデルの`search`メソッドを読んでUserモデル側で処理する end
# user.rb def self.search(search) if search where('name LIKE ?', "%#{search}%") else all end end
最初コントローラーでやってたんだけど、Railsチュートリアルにある例を見るとSQL文はモデルで組み立てる。
ユーザーモデルの方は何も問題なかったのだが、マイクロポストモデルの方では戻り値がマイクロポストじゃないから上手くモデル側で処理できなかった。 こういう場合は
# ユーザーの検索処理の場合 (byebug) User.paginate(page: params[:page]).class User::ActiveRecord_Relation (byebug) User.paginate(page: params[:page]).search("User") # Userモデルに定義した`search`メソッドが呼べる。 User Load (0.7ms) SELECT "users".* FROM "users" WHERE (name LIKE '%User%') LIMIT ? OFFSET ? [["LIMIT", 30], ["OFFSET", 0]] #<ActiveRecord::Relation [#<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2017-08-31 03:24:43", updated_at: "2017-08-31 04:05:36", password_digest: "$2a$10$c6SeUXq5hRG9FLJqNIM3EOUBiRuv/CXa3C3u6vEgRky...", remember_digest: "$2a$10$FPWHJckDrGrMyR7wTT.RjeybzbZjmR2uHBVtPPoYwGm...", admin: true, activation_digest: "$2a$10$Ww6ud8vjul5IqocU7vvQTecZYH4dqaBS0MuPAvQNDcQ...", activated: true, activated_at: "2017-08-31 03:24:43", reset_digest: nil, reset_sent_at: nil>]> # マイクロポストの検索処理の場合 (byebug) @user.microposts.paginate(page: params[:page]).search(params[:search]) # そんなメソッドねーよってなっちゃう *** NoMethodError Exception: undefined method `search' for #<Micropost::ActiveRecord_AssociationRelation:0x007fb506959698> Did you mean? each (byebug) @user.microposts.paginate(page: params[:page]).class Micropost::ActiveRecord_AssociationRelation
うーん。普通にMicropostモデルのsearch
メソッドが呼べそうだけど、何がダメなんだろうか。
railsチュートリアル検索機能の拡張のまとめ
当たり前だけど新しい機能を実装していくと全て考えて試行錯誤しながらやらないといけない。
Railsチュートリアルをやっていると訳分からずでも同じようにコピーすれば、とりあえず動く。だけどそうはいかない。エラーしか出てこない。
だけどこうやって頭使ってやっていくことが一番成長するのだろうと思う。
まだまだ全然思い通りにならない。今回の場合はお手本もあり何とか動くところまで持っていけた。
だけどきっと少しずつ出来るようにはなっているはずだから、手を止めずに前向いて頑張っていこう!
railsチュートリアル検索機能の拡張の追記
Nfm4yxnW8さんご指摘いただきました。
入門者に無粋な指摘で申し訳ないけどLIKE injectionを実行できちゃうよ。ここでは大きな問題にならないけど、他の場所では致命傷になるかも。 http://euglena1215.hatenablog.jp/entry/2016/09/22/171850 https://githubengineering.com/like-injection/
僕が書いたコードだと、「Likeインジェクション」っていう脆弱性がある。ユーザーが入力された文字をそのままSQLの条件にしてしまっているからだ。
教えていただいたサイトと同じようにやってみる
# params[:search]に「');--」が入ってくると... (byebug) params[:search] "');--" (byebug) @user.microposts.paginate(page: params[:page]).where('content LIKE ?', "%#{params[:search]}%").count (0.2ms) SELECT COUNT(*) FROM "microposts" WHERE "microposts"."user_id" = ? AND (content LIKE '%'');--%') [["user_id", 1]] 0
# params[:search]に「’ AND password LIKE ‘password’);—」が入ってくると... (byebug) params[:search] "’ AND password LIKE ‘password’);—" (byebug) @user.microposts.paginate(page: params[:page]).where('content LIKE ?', "%#{params[:search]}%").count (0.3ms) SELECT COUNT(*) FROM "microposts" WHERE "microposts"."user_id" = ? AND (content LIKE '%’ AND password LIKE ‘password’);—%') [["user_id", 1]] 0
ごめんなさい。SQL力が低すぎてどうやったらダメなのか分からなかったから、例題通りやってみたのだけど上手くいかなかった。改めて勉強します。
とはいえ、何か上手くやれば攻撃されちゃうってことなので、こういった記号はエスケープされるように処理する。
# あー多分コントローラーじゃ使えないんだなー後で直す @user.microposts.paginate(page: params[:page]).where('content LIKE ?', "%#{sanitize_sql_like(params[:search])}%") NoMethodError (undefined method `sanitize_sql_like' for #<UsersController:0x007ffcddb73ce8>):
# userモデル def self.search(search) if search where('name LIKE ?', "%#{sanitize_sql_like(search)}%") else all end end # コンソール (byebug) params[:search] "');--" (byebug) User.paginate(page: params[:page]).search(params[:search]) User Load (0.3ms) SELECT "users".* FROM "users" WHERE (name LIKE '%'');--%') LIMIT ? OFFSET ? [["LIMIT", 30], ["OFFSET", 0]] #<ActiveRecord::Relation []>
うーん。エラーにはならなかったけどエスケープされているようには見えない。 うーん。よくわからないな。
とりあえずタイムアップ。また調べてみよう。