おぴよの気まぐれ日記

おぴよの気まぐれ日記

岡山やプログラミング、ファッションのこと、子育てや人生、生き方についての備忘録。

30歳まで残り2年の僕は人生を変えるためにRailsチュートリアルを始めようと思う(番外編:検索機能の拡張)

こんにちは。opiyoです。

今回は、番外編:検索機能の拡張をやっていきます。

ユーザーとマイクロポストをあいまい検索できるような機能を各画面に追加します。

ではでは、早速行ってみましょう。

ユーザー一覧に名前をあいまい検索できる

f:id:opiyotan:20170905090515p:plain

# 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

ユーザー詳細のマイクロポストのコンテンツをあいまい検索できる

【検索前】

f:id:opiyotan:20170905090343p:plain

【検索後】

f:id:opiyotan:20170905090442p:plain

# 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

トップページのマイクロポストのコンテンツをあいまい検索できる

f:id:opiyotan:20170905085817p:plain

# 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

ハマった/気づきポイント

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チュートリアルをやっていると訳分からずでも同じようにコピーすれば、とりあえず動く。だけどそうはいかない。エラーしか出てこない。

だけどこうやって頭使ってやっていくことが一番成長するのだろうと思う。

まだまだ全然思い通りにならない。今回の場合はお手本もあり何とか動くところまで持っていけた。

だけどきっと少しずつ出来るようにはなっているはずだから、手を止めずに前向いて頑張っていこう!

追記

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 []>

うーん。エラーにはならなかったけどエスケープされているようには見えない。 うーん。よくわからないな。

とりあえずタイムアップ。また調べてみよう。