Ruby on Railsにおいてセレクトボックスから複数のデータを取り出す作業が意外と複雑な為、これまで試行錯誤した過程をこちらで整理しています。
まだ解決済みではありませんので、進捗が有り次第更新していきます(追記:問題解決しました。)
バージョン情報
ruby 2.6.4p104
RubyGems 3.0.3
Rails 5.2.3
やりたいこと
現在語学学習系SNSサイトを制作しています。今回はプロフィール編集画面で、ユーザーが学習中の言語を複数選択出来るセレクトボックスを作成したいと思います。
最終的には世界中の言語が網羅されているgemを使おうと思うのですが、まずはテスト段階として「日本語、英語、中国語、スペイン語」の4言語が入ったセレクトボックスを作っていきます。
多対多の関係性
「ユーザー」と「言語」は多対多の関係にあると思いますので、「user_language」と言う中間テーブルを用い「user」と「language」を多対多の関係で結びました。
Userモデル
class User < ApplicationRecord
has_secure_password
has_many :languages, through: :user_language
has_many :user_language
def language_name
::LanguageSelect::LANGUAGES[language]
end
end
Languageモデル
class Language < ApplicationRecord
has_many :users, through: :user_language
has_many :user_language
end
UserモデルとLanguageモデルの中間テーブル
class Language < ApplicationRecord
belongs_to :user
belongs_to :language
end
schema
ActiveRecord::Schema.define(version: 2020_05_21_034306) do
create_table "languages", force: :cascade do |t|
t.string "description"
t.boolean "done"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_languages_on_user_id"
end
create_table "user_languages", force: :cascade do |t|
t.integer "user_id"
t.integer "language_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["language_id"], name: "index_user_languages_on_language_id"
t.index ["user_id"], name: "index_user_languages_on_user_id"
end
create_table "users", force: :cascade do |t|
t.string "name"
t.string "email"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.string "image_name"
t.string "password_digest"
t.string "cover_image_name"
t.string "sex"
t.string "country"
t.string "language"
t.text "introduction"
end
プロフィール編集ページは以下の様に記述。
<div class="col-1">Spoken language:</div>
<div class="col-2">
<select name="language[]" multiple="multiple" class="form-control">
<option>日本語</option>
<option>英語</option>
<option>中国語</option>
<option>スペイン語</option>
<input type="button" value="+" class="add pluralBtn">
<input type="button" value="-" class="del pluralBtn">
</div>
コントローラは以下の様に記述。
def update
@user = User.find_by(id: params[:id])
@user.languages = params[:language]
if @user.save
flash[:notice] = "ユーザー情報を編集しました"
redirect_to("/users/#{@user.id}")
else
render("users/edit")
end
end
最後にプロフィールページは以下の様に記述。
<div class="language">
<% @user.languages.each do |language| %>
<%= @user.language %>
<% end %>
</div>
HasManyThroughOrderのエラー
準備は整ったので複数の言語を選択し保存しようとすると、以下のエラーが発生しました。
ActiveRecord::HasManyThroughOrderError in UsersController#update
Cannot have a has_many :through association 'User#languages' which goes through 'User#user_language' before the through association is defined.
Extracted source (around line #125):
@user.languages = params[:language]
多対多の関係を記述する際に、中間テーブルに関するコード(ここではuser_language)を先に書かないと上記のエラーが発生するようです。
こちらの記事を参考にして、リレーションについて以下の様に記述し直しました。
Userモデル
class User < ApplicationRecord
has_secure_password
has_many :user_language
has_many :languages, through: :user_language
def language_name
::LanguageSelect::LANGUAGES[language]
end
end
Languageモデル
class Language < ApplicationRecord
has_many :user_language
has_many :users, through: :user_language
end
AssociationTypeMissmatchのエラー
書き直す事でHasManyThroughOrderのエラーは表示されなくなりましたが、今度は以下のエラーが表示されました。
ActiveRecord::AssociationTypeMismatch in UsersController#update
Language(#135869460) expected, got "英語" which is an instance of String(#21954020)
@user.languages = params[:language]
Languageのインスタンスを受け取るつもりが"英語"と言う文字列を受け取ってしまった、と言う内容のようです。
なのでそれを防ぐために、プロフィール編集ページにおける選択肢の言語それぞれにvalue属性を付与する事にしました。
<div class="col-1">Spoken language:</div>
<div class="col-2">
<select name="language[]" multiple="multiple" class="form-control">
<option>日本語</option>
<option>英語</option>
<option>中国語</option>
<option>スペイン語</option>
<input type="button" value="+" class="add pluralBtn">
<input type="button" value="-" class="del pluralBtn">
</div>
こうだった物を、以下の様に変更。
<div class="col-1">Spoken language:</div>
<div class="col-2">
<select name="language[]" multiple="multiple" class="form-control">
<option value="1">日本語</option>
<option value="2">英語</option>
<option value="3">中国語</option>
<option value="4">スペイン語</option>
<input type="button" value="+" class="add pluralBtn">
<input type="button" value="-" class="del pluralBtn">
</div>
再度AssociationTypeMissmatchのエラー
これで解決したかと思いきや、再び同じエラー文が。
ActiveRecord::AssociationTypeMismatch in UsersController#update
Language(#128573540) expected, got "2" which is an instance of String(#21954020)
@user.languages = params[:language]
今回こそはlanguage_id
を渡したつもりが、ここでもダブルクォーテーション付きの"2"
と言う「文字列」として渡ってしまっているようです。
実はこれは、Railsのvalueで送れるデータは文字列限定であると言う事が恐らくの原因でした。
中々上手くいかないとラジオボタンなどで済ませてしまいたくなる気持ちもありますが、最終的には100個以上の言語を選択肢に入れたいので、やはりここはセレクトボックスにこだわって頑張っていきます。
今後
取りあえず今はここで躓いている状態です。
細かく整理しますと、コントローラにおいてユーザーが学習中の言語の情報を以下の様に「@user.languages」と定義した場合はAssociationTypeMismatch
のエラーが表示されます。
def update
@user = User.find_by(id: params[:id])
@user.languages = params[:language]
if @user.save
flash[:notice] = "ユーザー情報を編集しました"
redirect_to("/users/#{@user.id}")
else
render("users/edit")
end
end
また以下の様に「@user.language」と単数形で定義した場合はエラーは表示されないものの、["1", "2"]
のように配列の形のまま(文字列として)出力されます。
def update
@user = User.find_by(id: params[:id])
@user.language = params[:language]
if @user.save
flash[:notice] = "ユーザー情報を編集しました"
redirect_to("/users/#{@user.id}")
else
render("users/edit")
end
end
日本語 中国語
のように、[]や""を含まない形でデータを個別に取り出し表示させるべく、解決方法を考えていきます。
今考えている方法は2つ:
方法その1
["1", "2"]
と言った文字列を受け取った後に数値型に変換する。
方法その2
データを受け取る前に数値型を指定出来るのなら指定する。
2つともどのように記述していけば良いか分からず現在試行錯誤している状況です。
解決策を期待して読んで下さった方には申し訳ありませんが、進捗が有り次第更新していきます。
追記(問題解決しました)
その後ご助言を頂けたので、コントローラの記述を変える所から始めました。
def update
@user = User.find_by(id: params[:id])
@user.languages = params[:language]
if @user.save
flash[:notice] = "ユーザー情報を編集しました"
redirect_to("/users/#{@user.id}")
else
render("users/edit")
end
end
上記記述だった物を、以下の様に変更しました(言語のデータを受け取る箇所)。
またその前後でのデータの受け渡しを確認する為、pryの記述も加えました。
binding.pry
@user.language_ids = params[:language]
@user.language = params[:language]
binding.pry
(_ids
の記述に関してはこちらで参照できますが、まだ完全な理解には至っておりません。)
https://railsguides.jp/association_basics.html#has-many%E3%81%A7%E8%BF%BD%E5%8A%A0%E3%81%95%E3%82%8C%E3%82%8B%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89-collection-singular-ids-ids
RecordNotFoundのエラー
上記のコードで試してみるも、再度エラーが表示されました。
今回複数選択した言語は2つだったので、「2つ分のidをLanguagesから取得しようとしたが、実際見つかったidは0個だった。3と4と言うidを持つLanguagesは見つかりません」と言う意味の様です。
ActiveRecord::RecordNotFound in UsersController#update
Couldn't find all Languages with 'id': (3, 4) (found 0 results, but was looking for 2). Couldn't find Languages with ids 3, 4.
@user.country = params[:country]
binding.pry
@user.language_ids = params[:language]
binding.pry
しかしここで、languagesテーブル
にそもそも言語のレコードが入っていない事が発覚しました。
「性別」や「国籍」などの項目と同じ様に、プロフィール編集ページ(views/users/edit.html.erb)
にオプションを羅列すれば良いのだと思っていましたが、「言語」は多対多でテーブルが別にあるため、そちらでのレコードの事前準備が必要なのだと言う事を知りました…。
pry(main)> Language.all
+----+-------------+------+---------+-------------------------+-------------------------+
| id | description | done | user_id | created_at | updated_at |
+----+-------------+------+---------+-------------------------+-------------------------+
| 1 | Japanese | | | 2020-09-29 07:05:30 UTC | 2020-09-29 07:05:30 UTC |
| 2 | English | | | 2020-09-29 07:06:11 UTC | 2020-09-29 07:06:11 UTC |
| 3 | Chinese | | | 2020-09-29 06:20:27 UTC | 2020-09-29 06:20:27 UTC |
| 4 | Spanish | | | 2020-09-29 06:21:00 UTC | 2020-09-29 06:21:00 UTC |
+----+-------------+------+---------+-------------------------+-------------------------+
上記のようにレコードを準備した上で、再度ビューにて言語を複数選択しました。するとpryからは以下の様に返ってきました。
UserLanguage Destroy (4.0ms) DELETE FROM "user_languages" WHERE "user_languages"."user_id" = ? AND "user_languages"."language_id" IN (?, ?) [["user_id", 13], ["language_id", 4], ["language_id", 3]]
↳ app/controllers/users_controller.rb:126
UserLanguage Create (0.6ms) INSERT INTO "user_languages" ("user_id", "language_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["user_id", 13], ["language_id", 1], ["created_at", "2020-09-29 07:07:38.908759"], ["updated_at", "2020-09-29 07:07:38.908759"]]
↳ app/controllers/users_controller.rb:126
UserLanguage Create (0.2ms) INSERT INTO "user_languages" ("user_id", "language_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["user_id", 13], ["language_id", 2], ["created_at", "2020-09-29 07:07:38.917779"], ["updated_at", "2020-09-29 07:07:38.917779"]]
language_id
4と3を選択していたユーザーの言語情報が、language_id
1と2にしっかりと上書きされたようです。更にはビューで以前出ていた「idが見つからないよ」と言うエラーも表示されなくなりました。
viewの記述
しかし今度はプロフィール画面に飛んでも、今度は言語の情報が上書きされずに以前のまま表示されるようになりました。以前保存していた["1", "3", "4"]
がそのまま表示されると言った具合です。
これは、viewで誤った記述をしていた事が原因でした。
<% @user.languages.each do |language| %>
<%= @user.language %>
<% end %>
上記の記述ですと、恐らくLanguage
のインスタンスを配列で受け取ってしまいます。
先ほどレコードに載せたように、language
テーブルにはid
やdescription
と言ったカラムがあるので、そこを指定する必要がありました。
ですので以下の様に記述する事で、日本語 中国語
のように、[]や""を含まない形でデータを個別に取り出す事に成功しました!
<% @user.languages.each do |language| %>
<%= language.description %>
<% end %>
おわりに
選んだ言語を表示するだけのシンプルな作業だと始める前は思っていましたが、実は多対多やデータベース等に関して様々な知識が必要で、デバッグツールやERD図なども使って考えていかないと解決が難しい題材でした。
今後このように複雑な問題に出くわした時は、デバッグツールを使うなどして、どの時点でおかしくなったのか問題を切り分けながら考えられるようになればと思います。
また欲を言えば便利なgemなども探していけたらと思いますが、取りあえず最低限の機能の実装が出来れば一先ずは良いので、追々変更があれば追記致します。