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.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
こうすると、 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
を使うと結合したテーブルをメモリに保持しておくことができる。