12. 検索¶
12.1. 多対多の対応¶
成績データを追加してみよう。
学生に対しても科目に対しても複数の成績データが存在するので、どちらのモデルにも入らない(関係データベースはカラムが固定なので、可変個のデータは入れられない)。
新しく
Scoreモデル(ある学生のある科目の成績ごとに1個のデータ)を作り、それをStudentやCourseと対応付ける。
安直に scaffold でコントローラとビューも作ってしまおう。カラムは学生ID、科目ID、評語の三つ。
% rails generate scaffold score student_id:integer course_id:integer score:string
% rails db:migrate
モデル間の対応は次のように定義する。詳しくは Active Record の関連付け (アソシエーション) - RailsGuides を見よ。
app/models/score.rbbelongs_to :student belongs_to :course
app/models/student.rbhas_many :scores has_many :courses, through: :scores
app/models/course.rbhas_many :scores has_many :students, through: :scores
こうすると、 Student と Course が Score を介して多対多の対応関係で結ばれる。例えば、 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>]
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' の次に何も書いていないので、自動的に StudentsController の search メソッドが呼び出される。ここには何も書くことはない。 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の引数はハッシュで、urlとmethodがキーになっている。urlは、このフォームのデータが送られる宛先のURL。result_students_pathは結果表示ページのパスを返すヘルパーメソッド。methodは、データを送る時のHTTPメソッド。Rails7ではgetを指定しないとTurboを使った非同期通信になり、表示にturbo-frameを使う必要がある。ちなみに Rails guide には、検索結果をブックマークしたりできるのでgetの方が良い(getリクエストは検索パラメータを含むURLになるので)と書いてある。
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 :indexはindex.html.erbをビューとして使うように指定する。
注釈
ここでは検索結果として学生の氏名など Studnet モデルの属性だけ表示しているが、履修科目や成績なども表示しようとすると、もう一度 Score モデルにアクセスする必要がある。そこで改めてデータベースにクエリを飛ばすのは効率が悪いので、 joins の代わりに preload や eager_load や includes を使うと結合したテーブルをメモリに保持しておくことができる。