12. 検索

12.1. 多対多の対応

成績データを追加してみよう。

  • 学生に対しても科目に対しても複数の成績データが存在するので、どちらのモデルにも入らない(関係データベースはカラムが固定なので、可変個のデータは入れられない)。

  • 新しく Score モデル(ある学生のある科目の成績ごとに1個のデータ)を作り、それを StudentCourse と対応付ける。

安直に scaffold でコントローラとビューも作ってしまおう。カラムは学生ID、科目ID、評語の三つ。

% rails generate scaffold score student_id:integer course_id:integer score:string
% rails db:migrate

モデル間の対応は次のように定義する。詳しくは Active Record の関連付け (アソシエーション) - RailsGuides を見よ。

  • app/models/score.rb

    belongs_to :student
    belongs_to :course
    
  • app/models/student.rb

    has_many :scores
    has_many :courses, through: :scores
    
  • app/models/course.rb

    has_many :scores
    has_many :students, through: :scores
    

こうすると、 StudentCourseScore を介して多対多の対応関係で結ばれる。例えば、 http://localhost:3000/scores/new で1番の学生の成績データを入力しておくと、 Student.find(1).courses で履修した科目の配列が得られる。

root@c65985cfc603:/home/school# rails c
Loading development environment (Rails 7.0.4)
irb(main):001:0> Student.find(1).courses
  Student Load (1.2ms)  SELECT "students".* FROM "students" WHERE "students"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  Course Load (0.6ms)  SELECT "courses".* FROM "courses" INNER JOIN "scores" ON "courses"."id" = "scores"."course_id" WHERE "scores"."student_id" = ?  [["student_id", 1]]
=>
[#<Course:0x0000ffffb4d0e5a8
  id: 1,
  name: "体育1",
  credit: 1,
  compulsory: true,
  created_at: Wed, 14 Dec 2022 08:05:47.630120000 UTC +00:00,
  updated_at: Wed, 14 Dec 2022 08:05:47.630120000 UTC +00:00>]
_images/has-many-through.png

12.2. 検索に関するページのルーティング

ある科目である成績をとった学生を検索してみよう。検索条件の入力と、結果の表示の二種類のページが必要になる。

検索条件の入力ページを /students/search 、結果表示ページを /students/result と決めると、ルーティングは次のように書ける。

resources :students do
  collection do
    get 'search'
    get 'result'
  end
end
  • resources :students の後にブロックを付け、その中に collection do ... end を書く。

  • collection do ... end の中には単一ルーティングと同じ形(VerbとURLパターン)を書く。ただし、ここで書いたバターンは、 /students/ の後にくっつくので、結局 /students/search/students/result というURLになる。学生全体に対して一つのページを作りたい時に使う。

    • もし学生一人ずつにそれぞれページを作りたい場合は、 collection ではなく member を使う。その場合は、 /students/:id/ の後にくっつく。

12.3. 検索条件を入力するページ

/students/search にアクセスすると、 get 'search' の次に何も書いていないので、自動的に StudentsControllersearch メソッドが呼び出される。ここには何も書くことはない。 app/controllers/students_controller.rb に空のメソッドを定義しておく。

# GET /students/search
def search
end

何も書いていないので自動的に app/views/students/search.html.erb が呼び出される。そこに検索条件を入力するフォームを書く。

<h1>成績を指定して学生を検索</h1>

<%= form_with(url: result_students_path, method: :get) do |form| %>

  <div>
    <%= form.label :course, '科目' %>
    <%= form.collection_select :course, Course.all, :id, :name %>
  </div>

  <div>
    <%= form.label :score, '成績' %>
    <%= form.select :score, {'S' => 'S', 'A' => 'A', 'B' => 'B', 'C' => 'C', 'D' => 'D'} %>
  </div>

  <div>
    <%= form.submit '検索' %>
  </div>

<% end %>
  • form_with の引数はハッシュで、 urlmethod がキーになっている。

    • url は、このフォームのデータが送られる宛先のURL。 result_students_path は結果表示ページのパスを返すヘルパーメソッド。

    • method は、データを送る時のHTTPメソッド。Rails7では get を指定しないとTurboを使った非同期通信になり、表示に turbo-frame を使う必要がある。ちなみに Rails guide には、検索結果をブックマークしたりできるので get の方が良い( get リクエストは検索パラメータを含むURLになるので)と書いてある。

_images/search-get.png

12.4. 検索結果を表示するページ

検索ボタンを押すと、フォームに入力したデータが /students/result に送信される。

これに対応して StudentsController クラスに result メソッドを追加する。ここで検索を行うわけだが、二つのやり方がある。

  • とりあえずデータベースから全部データを取り出して、1個ずつRubyプログラムで条件が成り立つかどうか判定する。この例だと Student.all.select do |x| ... end というようなループで処理する。どんな条件でも書けるが、不要なデータもいったん取り出して処理するので遅い。また、データの件数が多い場合は、 all だと大量のメモリが必要になるので find_each などを使って少しずつ取り出すようにしなければならない。

  • 関係データベースの機能を使って、条件に合うデータだけを取り出す。条件はSQLか、SQLを生成するメソッドを使って書く。SQLがわかっていないとつらい。

ここでは2番目のやり方でやってみる。詳しくは Active Record クエリインターフェース - RailsGuides を見よ。

# POST /students/search
def result
  @students = Student.joins(:scores).where('course_id = ? AND score = ?', params[:course], params[:score])
  render :index
end
  • Student.joins(:scores) で学生と成績のテーブルを結合する。正確には SELECT students.* FROM students INNER JOIN scores ON students.id = scores.student_id というSQLを生成する。

  • where の引数に条件を書く。第一引数の中の ? は、順にその後の引数で置き換えられる(この時、SQLで特別な意味を持つ文字はエスケープされる)。このようにしないで "course_id = #{params[:course]}" のように第一引数の中に直接パラメータを埋め込むと SQL injection というセキュリティホールになる。

  • 関係データベースの中では学生と成績のテーブルを結合して検索が行われるが、Rubyの世界ではクラスが結合されるわけではないので、検索結果は joins メソッドを呼び出したクラス(この場合は Student )のインスタンスの配列になる。

  • 最後に検索結果を表示する。新しくERBファイルを作ってもよいが、面倒なので一覧表示用のビューを使いまわす。検索結果を代入する変数が @students であり、一覧表示の時と同じ変数名であるところがミソ。 render :indexindex.html.erb をビューとして使うように指定する。

注釈

ここでは検索結果として学生の氏名など Studnet モデルの属性だけ表示しているが、履修科目や成績なども表示しようとすると、もう一度 Score モデルにアクセスする必要がある。そこで改めてデータベースにクエリを飛ばすのは効率が悪いので、 joins の代わりに preloadeager_loadincludes を使うと結合したテーブルをメモリに保持しておくことができる。

_images/search-post.png

12.5. 今日のまとめ

ここまでのソースコードはこちら