20210417のRailsに関する記事は23件です。

enumとenum_helpの使い方【rails】

※プログラミング初学者の方向けに記事を書いています。 自分が遭遇したエラーなどに同じように遭遇するかもなのでご参考になれば enumって何?? モデルで数値のデータ型で定義しているカラムを、 文字列型として使えるようにすることができます。 例えば、0は男性、1は女性みたいな感じです。 定義方法 enumはモデルに定義する必要があります。 定義の仕方は主に2つあります。 ・名前だけを定義する方法 ・名前とそれに対応する数値を定義する方法 では、1つずつ見ていきましょう! 1.名前だけを定義する方法 まず、前提としてUserモデルのroleカラムがあるという前提で説明していきます。 models/user.rb class User < ApplicationRecord enum role: [ :general, :admin ] end 上記のように定義します。 「role」の部分がカラム名です。 その横に配列として、使いたい名前を定義していき、左から順番に0から数字が紐づけられていきます。 上の例でいうと、 「general: 0, admin: 1」という風になります。 ちなみ後から下記のように定義を追加した場合、 models/user.rb class User < ApplicationRecord enum role: [ :general, :editor, :admin ] end 「general: 0, editor: 1, admin: 2」と言う風になります。 つまり、後から定義したとしても、数字は前から順に割り振られていきます。 なので、仮にすでにデータベースに「admin」が保存されていた場合、 それが「editor」ということになってしまいます。 2.名前とそれに対応する数値を定義する方法 models/user.rb class User < ApplicationRecord enum role: { general: 0, admin: 1 } end こちらの方法は、明示的に名前と対応する数字を定義します。 ちなみに、こちらの方法は後から定義を追加する場合は 下記のように数字を明示的に指定する必要があります。 models/user.rb class User < ApplicationRecord enum role: { general: 0, editor: 2, admin: 1 } end なので、先程のようにデータが途中から変わってしまうというようなことは起こり得ません。 明示的に指定したほうがわかりやすいので私はこちらの方法を推奨します。 また、一番最初に定義している値、つまり「general: 0」の値をデフォルトとしてモデルに定義すると、より良いです。 マイグレーションファイル class AddRoleToUsers < ActiveRecord::Migration[5.2] def change add_column :users, :role, :integer, null: false, default: 0 end end コンソールで使ってみる コンソール irb(main):001:0> User.roles ※定義済のカラムを参照 => {"general"=>0, "admin"=>1} ※ハッシュ形式で保存されている irb(main):002:0> User.roles[:general] ※ハッシュなので一項目だけ参照することも可能 => 0 irb(main):003:0> User.roles[:admin] => 1 irb(main):004:0> user = User.first irb(main):005:0> user.general? ※このような便利なメソッドも使えるようになります。 => false irb(main):006:0> user.admin? => true enum_helpって何?? enum_helpとはenumで定義した値をi18n化させることができるgemです! 現段階では下記のようになります。 コンソール irb(main):007:0> user.role => "admin" ※これを日本語にしたい! 導入方法 導入方法は簡単! enum_helpというgemを追加して、localeに翻訳を設定するだけです! Gemfile gem 'enum_help' $ bundle install config/locales/activerecord/ja.yml ja: enums: user: role: general: 一般 admin: 管理者 コンソール irb(main):007:0> user.role => "admin" irb(main):008:0> user.role_i18n ※カラム名のあとに「_i18n」をつけて呼び出す => "管理者" enum_helpを導入すると、便利なヘルパーも追加されます。 コンソール irb(main):012:0> User.roles => {"general"=>0, "admin"=>1} irb(main):011:0> User.roles_i18n => {"general"=>"一般", "admin"=>"管理者"} irb(main):013:0> User.roles_i18n.invert => {"一般"=>"general", "管理者"=>"admin"} ※キーとバリューが入れ替わる! このinvertメソッドはセレクトボックスを使う時やransackを利用した時などに有効です! ※間違いなどあればコメントください!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Railsチュートリアル 第6版 振り返り 第10章

ユーザーの更新・表示・削除(第10章) はじめに このまとめ記事はRailsチュートリアルを1周終えた私が1周目で分からなかった所や記憶に残したい箇所のみをピックアップして記述しています。完全解説記事ではないので注意して下さい。 私と同じく2周目の方、たまに復習したいなと振り返りを行う方等におすすめです。 とは言いつつも、9章あたりから難しくなったのでこの章も全て書き残しています。 この章では、Usersリソース用のRESTアクションでまだ実装していない、edit、update、index、destroyアクションを実装してRESTアクションを完成させる。 ユーザーを更新する(10.1) ユーザー情報の編集は、新規ユーザーの作成と似ている。 新規ユーザー用ビューを出力するnewアクションと同じようにして、ユーザーを編集するためのeditアクションを作成すれば良い。 同様にPOSTリクエストに応答するcreateの代わりに、PATCHリクエストに応答するupdateアクションを作成する。 最大の違いは、ユーザー登録は誰でも実行できるが、ユーザー情報を更新できるのはそのユーザー自身に限られるということ。 8章で実装した認証機能を使用すると、beforeフィルター(before filter)を使ってこのアクセス制御を実現できる。 編集フォーム(10.1.1) まずは編集フォームから始める。 最初にUsersコントローラにeditアクションを追加して、それに対応するeditビューを実装する。 editアクションの実装は、データベースから適切なユーザーデータを読み込む必要がある。 さらに注目したいのが、ユーザー編集ページの正しいURLが/users/1/editとなっていること。 (ユーザーIDが1の場合。) ユーザーのidはparams[:id]変数で取り出すことができた。 app/controllers/users_controller.rb def edit @user = User.find(params[:id]) end そのため上のコードでユーザーの指定が可能となる。 そして次はビューを作成。 app/views/users/edit.html.erb <% provide(:title, "Edit user") %> <h1>Update your profile</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= form_with(model: @user, local: true) do |f| %> <%= render 'shared/error_messages' %> <%= f.label :name %> <%= f.text_field :name, class: 'form-control' %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation, "Confirmation" %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit "Save changes", class: "btn btn-primary" %> <% end %> <div class="gravatar_edit"> <%= gravatar_for @user %> <a href="https://gravatar.com/emails" target="_blank">change</a> </div> </div> </div> このコードではerror_messagesパーシャルを再利用している。 target="_blankを使うとリンク先を新しいタブ(またはウィンドウ)で開くようになる。 別のWebサイトへリンクするときなどに便利。 (ただし、セキュリティ上の問題あり。後の演習で詳しく確認。) そして実際に編集ページにアクセスしてみる。 引用:Railsチュートリアル10.1.1 すると名前とメールアドレスが自動で入力されていることが分かる。 これらの値は、@user変数の属性情報から引き出されている。 そしてこのHTMLコードを実際に確認してみる。 <form accept-charset="UTF-8" action="/users/1" class="edit_user" id="edit_user_1" method="post"> <input name="_method" type="hidden" value="patch" /> . . . </form> 次の入力フィールドには、隠し属性がある。 <input name="_method" type="hidden" value="patch" /> WebブラウザはそのままではPATCHリクエストを送信できないため、RailsはPOSTリクエストと、隠しinputフィールドを利用してPATCHリクエストを「偽造」している。 詳しくはこちらを拝見させていただきました。 <INPUT type="hidden">-HTMLタグリファレンス そしてこれ以外にも1つ微妙な点がある。 form_with(@user)のコードが新規ユーザーのためのユーザー登録フォームと全く同じこと。 これでは、Railsはどうやって新規ユーザー用のPOSTリクエストとユーザー編集用のPATCHリクエストを区別しているのだろうか。 答えは、Railsは、ユーザーが新規なのか、それともデータベースに存在する既存のユーザーであるかを、Active Recordのnew_record?論理値メソッドを使って区別できるから。 $ rails console >> User.new.new_record? => true >> User.first.new_record? => false Railsは、form_with(@user)を使ってフォームを構成すると、@user.new_record?がtrueのときにはPOSTを、falseのときにはPATCHを使う。 仕上げに、ナビゲーションバーにあるユーザー設定へのリンクを更新。 名前つきルートedit_user_pathと、current_userヘルパーメソッドを利用すると簡単に実装できる。 <%= link_to "Settings", edit_user_path(current_user) %> 上のコードを実装した完全版のビューがこれ。 app/views/layouts/_header.html.erb <header class="navbar navbar-fixed-top navbar-inverse"> <div class="container"> <%= link_to "sample app", root_path, id: "logo" %> <nav> <ul class="nav navbar-nav navbar-right"> <li><%= link_to "Home", root_path %></li> <li><%= link_to "Help", help_path %></li> <% if logged_in? %> <li><%= link_to "Users", '#' %></li> <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown"> Account <b class="caret"></b> </a> <ul class="dropdown-menu"> <li><%= link_to "Profile", current_user %></li> <li><%= link_to "Settings", edit_user_path(current_user) %></li> <li class="divider"></li> <li> <%= link_to "Log out", logout_path, method: :delete %> </li> </ul> </li> <% else %> <li><%= link_to "Log in", login_path %></li> <% end %> </ul> </nav> </div> </header> 演習 1 target="_blank"で新しいページを開くときには、セキュリティ上の小さな問題がある。 それは、リンク先のサイトがHTMLドキュメントのwindowオブジェクトを扱えてしまう、という点。 具体的には、フィッシングサイトのような、悪意のあるコンテンツを導入させられてしまう可能性がある。 念のため、このセキュリティ上のリスクも排除しておく。 対処方法は、リンク用のaタグのrel(relationship)属性に、"noopener"と設定するだけ。 <div class="gravatar_edit"> <%= gravatar_for @user %> <a href="https://gravatar.com/emails" target="_blank" rel=”noopener”>change</a> </div> 2 _form.html.erbというパーシャルを作成して、new.html.erbビューとedit.html.erbビューをリファクタリング(コードの重複を取り除く)する。 app/views/users/_form.html.erb <%= form_with(model: @user, local: true) do |f| %> <%= render 'shared/error_messages', object: @user %> <%= f.label :name %> <%= f.text_field :name, class: 'form-control' %> <%= f.label :email %> <%= f.email_field :email, class: 'form-control' %> <%= f.label :password %> <%= f.password_field :password, class: 'form-control' %> <%= f.label :password_confirmation %> <%= f.password_field :password_confirmation, class: 'form-control' %> <%= f.submit yield(:button_text), class: "btn btn-primary" %> <% end %> app/views/users/new.html.erb <% provide(:title, 'Sign up') %> <% provide(:button_text, 'Create my account') %> <h1>Sign up</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= render 'form' %> </div> </div> app/views/users/edit.html.erb <% provide(:title, 'Edit user') %> <% provide(:button_text, 'Save changes') %> <h1>Update your profile</h1> <div class="row"> <div class="col-md-6 col-md-offset-3"> <%= render 'form' %> <div class="gravatar_edit"> <%= gravatar_for @user %> <a href="https://gravatar.com/emails" target="_blank">Change</a> </div> </div> </div> 編集の失敗(10.1.2) この項では、ユーザー登録に失敗したときと似た方法で、編集に失敗した場合について扱う。 まずはupdateアクションの作成。 updateを使って送信されたparamsハッシュに基づいてユーザーを更新する。 無効な情報が送信された場合、更新の結果としてfalseが返され、elseに分岐して編集ページをレンダリングする。 この構造はcreateアクションと似通っている。 app/controllers/users_controller.rb def update @user = User.find(params[:id]) if @user.update(user_params) # 更新に成功した場合を扱う。 else render 'edit' end end updateへの呼び出しはuser_paramsを使用する。 バリデーションとエラーメッセージの出力は既に定義済みなため、エラーも発生するし、エラーメッセージも出力されるようになっている。 編集失敗時のテスト(10.1.3) この項では、編集失敗時のエラーを検知するための統合テストを書く。 $ rails generate integration_test users_edit invoke test_unit create test/integration/users_edit_test.rb 最初は編集失敗時の簡単なテストを追加する。 まず編集ページにアクセスし、editビューが描画されるかどうかをチェック。 その後、無効な情報を送信してみて、editビューが再描画されるかどうかをチェック。 test/integration/users_edit_test.rb require 'test_helper' class UsersEditTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end test "unsuccessful edit" do get edit_user_path(@user) assert_template 'users/edit' patch user_path(@user), params: { user: { name: "", email: "foo@invalid", password: "foo", password_confirmation: "bar" } } assert_template 'users/edit' end end 演習 1 テストを1行追加し、正しい数のエラーメッセージが表示されるかテスト。 test/integration/users_edit_test.rb assert_select "div.alert", "The form contains 4 errors." TDDで編集を成功させる(10.1.4) 今度は編集フォームが動作するようにする。 プロフィール画像の編集は、画像のアップロードをGravatarに任せてあるので、既に動作するため、それ以外の機能を実装していく。 より快適にテストをするためには、アプリケーション用のコードを「実装する前に」統合テストを書いた方が便利。 実際、そういったテストのことは「受け入れテスト(Acceptance Tests)」として呼ばれていて、ある機能の実装が完了し、受け入れ可能な状態になったかどうかを決めるテストとして知られている。 この項では、これを実際に体験するため、テスト駆動開発を使ってユーザーの編集機能を実装してみる。 まずは、ユーザー情報を更新する正しい振る舞いをテストで定義。 次に、flashメッセージが空でないかどうかと、プロフィールページにリダイレクトされるかどうかをチェック。 また、データベース内のユーザー情報が正しく変更されたかどうかも検証する。 test/integration/users_edit_test.rb test "successful edit" do get edit_user_path(@user) assert_template 'users/edit' name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } } assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end end 上のコードが実際のコード。 パスワードとパワスワード確認は空になっている。 ユーザー名やメールアドレスを編集するときに毎回パスワードを入力するのは不便なため、(パスワードを変更する必要が無いときは)パスワードを入力せずに更新する。 また、@user.reloadを使って、データベースから最新のユーザー情報を読み込み直して、正しく更新されたかどうかを確認している点も注目。 このような正しい振る舞いは、一般に忘れがちだが、受け入れテスト(もしくは一般的なテスト駆動開発)では先にテストを書くため、効果的なユーザー体験について考えるようになる。 そしてこのパスをテストさ成功させるための、updateアクションはcreateアクションの最終的なフォームとほぼ同じになる。 app/controllers/users_controller.rb def update @user = User.find(params[:id]) if @user.update(user_params) flash[:success] = "Profile updated" redirect_to @user else render 'edit' end end しかしこれでもテストはまだ失敗。 理由は、パスワードの長さに対するバリデーションがあるため、パスワードやパスワード確認の欄を空にしておくとこれに引っかかってしまうから。 テストが成功するためには、パスワードのバリデーションに対して、空だった時の例外処理を加える必要がある。 このような場合は、allow_nil: trueというオプションを使用する。 app/models/user.rb #class User < ApplicationRecord #attr_accessor :remember_token #before_save { self.email = email.downcase } #validates :name, presence: true, length: { maximum: 50 } #VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i #validates :email, presence: true, length: { maximum: 255 }, #format: { with: VALID_EMAIL_REGEX }, #uniqueness: true #has_secure_password validates :password, presence: true, length: { minimum: 6 }, allow_nil: true #end これによって、新規ユーザー登録時に空のパスワードが有効になるのではないかと疑問が生じる。 しかしそれは心配ないようで、has_secure_passwordでは(追加したバリデーションとは別に)オブジェクト生成時に存在性を検証するようになっているため、空のパスワード(nil)が新規ユーザー登録時に有効になることはないそう。 また第7章で、空のパスワードを入力すると存在性のバリデーションとhas_secure_passwordによるバリデーションがそれぞれ実行され、2つの同じエラーメッセージが表示されるというバグがあったが、これで解決できた。 認可(10.2) ウェブアプリケーションの文脈では、認証(authentication)はサイトのユーザーを識別することであり、認可(authorization)はそのユーザーが実行可能な操作を管理すること。 現段階で、editアクションとupdateアクションはすでに完全に動作してているが、セキュリティ上に問題がある。 それは、どのユーザーでもあらゆるアクションにアクセスできるため、誰でも(ログインしていないユーザーでも)ユーザー情報を編集できてしまうこと。 この節では、ユーザーにログインを要求し、かつ自分以外のユーザー情報を変更できないように制御する。 ログインしていないユーザーが保護されたページにアクセスしようとした際は、ログインページに転送して、そのときに分かりやすいメッセージも表示する。 ユーザーにログイン要求する(10.2.1) ログインページに転送させるためには、Usersコントローラの中でbeforeフィルターを使用する。 beforeフィルターは、before_actionメソッドを使って何らかの処理が実行される直前に特定のメソッドを実行する仕組み。 今回はユーザーにログインを要求するために、logged_in_userメソッドを定義してbefore_action :logged_in_userという形式で使用。 app/controllers/users_controller.rb class UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] . . . private def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end # beforeアクション # ログイン済みユーザーかどうか確認 def logged_in_user unless logged_in? #ユーザーがログイン状態になければfalseとなり、下の処理へ flash[:danger] = "Please log in." redirect_to login_url end end end デフォルトでは、beforeフィルターはコントローラ内のすべてのアクションに適用されてしまう。 onlyオプション(ハッシュ)を渡すことで、:editとupdateアクションだけにこのフィルタが適用されるように制限をかけている。 beforeフィルターを使って実装した結果、一度ログアウトしてユーザー編集ページ(/users/1/edit)にアクセスしてみるとしっかりと対策ができている。 しかしテストを行ってみると失敗してしまう。 原因は、editアクションやupdateアクションでログインを要求するようになったため、ログインしていないユーザーだとこれらのテストが失敗するようになったから。 そのため、editアクションやupdateアクションをテストする前にログインしておく必要がある。 これはとても簡単で、9章で用意したlog_in_asヘルパーを使用すると解決する。 test/integration/users_edit_test.rb test "unsuccessful edit" do log_in_as(@user) get edit_user_path(@user) . . . end test "successful edit" do log_in_as(@user) get edit_user_path(@user) . . . end end これでテストは成功する。 しかし、これでもまだbeforeフィルターの実装は終わっていない。 before_action :logged_in_user, only: [:edit, :update]をコメントアウトしてもテストが成功してしまうのだ。 この問題を解決していく。 beforeフィルターは基本的にアクションごとに適用していくので、Usersコントローラのテストもアクションごとに書いていく。 具体的には、正しい種類のHTTPリクエストを使ってeditアクションとupdateアクションをそれぞれ実行させる。 そして、flashにメッセージが代入されたかどうか、ログイン画面にリダイレクトされたかどうかを確認する。 ここでの適切なリクエストはeditアクション=GET、updateアクション=PATCHとなる。 test/controllers/users_controller_test.rb require 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end . . . test "should redirect edit when not logged in" do get edit_user_path(@user) assert_not flash.empty? assert_redirected_to login_url end test "should redirect update when not logged in" do patch user_path(@user), params: { user: { name: @user.name, email: @user.email } } assert_not flash.empty? assert_redirected_to login_url end end 先程の行をコメントアウトしたままテストを行うとしっかり失敗。 元に戻すとテストが成功した。 正しいユーザーを要求する(10.2.2) この項では、ユーザーが自分の情報だけを編集できるようにする。 セキュリティモデルが正しく実装されている確信を持つために、テスト駆動開発で進めていく。 したがって、Usersコントローラのテストを補完するように、テストを追加するところから始める。 まずはユーザーの情報が互いに編集できないことを確認するために、サンプルユーザーをもう一人追加。 test/fixtures/users.yml archer: name: Sterling Archer email: duchess@example.gov password_digest: <%= User.digest('password') %> 次に、 log_in_asメソッドを使って、editアクションとupdateアクションをテスト。 このとき、既にログイン済みのユーザーを対象としているため、ログインページではなくルートURLにリダイレクトさせることに注意。 test/controllers/users_controller_test.rb def setup @user = users(:michael) @other_user = users(:archer) end . . . test "should redirect edit when logged in as wrong user" do log_in_as(@other_user) get edit_user_path(@user) assert flash.empty? assert_redirected_to root_url end test "should redirect update when logged in as wrong user" do log_in_as(@other_user) patch user_path(@user), params: { user: { name: @user.name, email: @user.email } } assert flash.empty? assert_redirected_to root_url end end 別のユーザーのプロフィールを編集しようとしたらリダイレクトさせたいので、correct_userというメソッドを作成し、beforeフィルターからこのメソッドを呼び出すようにする。 app/controllers/users_controller.rb class UsersController < ApplicationController before_action :logged_in_user, only: [:edit, :update] before_action :correct_user, only: [:edit, :update] . . . def edit end def update if @user.update(user_params) flash[:success] = "Profile updated" redirect_to @user else render 'edit' end end . . . private def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end # beforeアクション # ログイン済みユーザーかどうか確認 def logged_in_user unless logged_in? flash[:danger] = "Please log in." redirect_to login_url end end # 正しいユーザーかどうか確認 def correct_user @user = User.find(params[:id]) redirect_to(root_url) unless @user == current_user end end beforeフィルターのcorrect_userで@user変数を定義しているため、editとupdateの各アクションから、@userへの代入文を削除している。 これでテストは成功する。 最後にリファクタリングとして、current_user?という論理値を返すメソッドを実装。 correct_userというbefore filterの中で使えるようにしたいので、Sessionsヘルパーの中にこのメソッドを追加。 このメソッドを使用すれば、 unless @user == current_user 上のコードが unless current_user?(@user) このように定義できる。 そして実際にメソッドを定義。 app/helpers/sessions_helper.rb # 渡されたユーザーがカレントユーザーであればtrueを返す def current_user?(user) user && user == current_user end メソッドを定義したらcorrect_userを書き換える。 app/controllers/users_controller.rb def correct_user @user = User.find(params[:id]) redirect_to(root_url) unless current_user?(@user) end フレンドリーフォワーディング(10.2.3) ここまででWebサイトの認可機能は完成したかのように見えるが、後1つ小さなキズがある。 それは、保護されたページにアクセスしようとすると、問答無用で自分のプロフィールページに移動させられてしまうこと。 別の言い方をすれば、ログインしていないユーザーが編集ページにアクセスしようとしていたなら、ユーザーがログインした後にはその編集ページにリダイレクトされるようにするのが望ましい動作。 リダイレクト先は、ユーザーが開こうとしていたページにしてあげるのが親切。 実際のコードは少し複雑だが、フレンドリーフォワーディング1のテストは非常にシンプルに書くことができるらしい。 ログインした後に編集ページへアクセスする、という順序を逆にするだけ。 実際のテストはまず編集ページにアクセスし、ログインした後に、(デフォルトのプロフィールページではなく)編集ページにリダイレクトされているかどうかをチェックするといったテスト。 test/integration/users_edit_test.rb test "successful edit with friendly forwarding" do get edit_user_path(@user) log_in_as(@user) assert_redirected_to edit_user_url(@user) name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } } assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end end ユーザーを希望のページに転送するには、リクエスト時点のページをどこかに保存しておき、その場所にリダイレクトさせる必要がある。 この動作をstore_locationとredirect_back_orの2つのメソッドを使って実現する。 なお、これらのメソッドはSessionsヘルパーで定義する。 app/helpers/sessions_helper.rb # 記憶したURL(もしくはデフォルト値)にリダイレクト def redirect_back_or(default) redirect_to(session[:forwarding_url] || default) session.delete(:forwarding_url) end # アクセスしようとしたURLを覚えておく def store_location session[:forwarding_url] = request.original_url if request.get? end end 転送先のURLを保存する方法はログインと同じで、session変数を使用する。 tore_locationメソッドは、リクエストが送られたURLをsession変数の:forwarding_urlキーに格納している。 ただし、GETリクエストが送られたときだけ格納するようにしておく。 これによって、ログインしていないユーザーがフォームを使って送信した場合、転送先のURLを保存させないようにできる。 さらに、例えばユーザがセッション用のcookieを手動で削除してフォームから送信するケース。 これは稀なケースだが起こりうる。 こういったケースに対処しておかないと、POSTや PATCH、DELETEリクエストを期待しているURLに対して、(リダイレクトを通して)GETリクエストが送られてしまい、場合によってはエラーが発生する。 このため、if request.get?という条件文を使ってこのケースに対応している。 それでは実際に定義したメソッドを使って、beforeフィルターを修正してみる。 app/controllers/users_controller.rb # ログイン済みユーザーかどうか確認 #def logged_in_user #unless logged_in? store_location #flash[:danger] = "Please log in." #redirect_to login_url #end #end フォワーディング自体を実装するには、redirect_back_orメソッドを使用。 リクエストされたURLが存在する場合はそこにリダイレクトし、ない場合は何らかのデフォルトのURLにリダイレクトする。 デフォルトのURLは、Sessionコントローラのcreateアクションに追加し、サインイン成功後にリダイレクトする。 redirect_back_orメソッドでは、次のようにor演算子||を使用。 session[:forwarding_url] || default このコードは、値がnilでなければsession[:forwarding_url]を評価し、そうでなければデフォルトのURLを使う。 またsession.delete(:forwarding_url)という行を通して、転送用のURLを削除している。 これをやっておかないと、次回ログインしたときに保護されたページに転送されてしまい、ブラウザを閉じるまでこれが繰り返されてしまうから。 ちなみに、最初にredirect文を実行しても、セッションが削除される。 実は、明示的にreturn文やメソッド内の最終行が呼び出されない限り、リダイレクトは発生しない。 したがって、redirect文の後にあるコードでも、そのコードは実行される。 app/controllers/sessions_controller.rb class SessionsController < ApplicationController . . . #def create #user = User.find_by(email: params[:session][:email].downcase) #if user && user.authenticate(params[:session][:password]) #log_in user #params[:session][:remember_me] == '1' ? remember(user) : forget(user) redirect_back_or user #else #flash.now[:danger] = 'Invalid email/password combination' #render 'new' #end #end . . . #end これで、フレンドリーフォワーディング用統合テストは全て成功する。 成功すれば、基本ユーザー認証機能とページ保護機能の実装は完了。 演習 1 フレンドリーフォワーディングで、渡されたURLに初回のみ転送されていることを、テストを書いて確認。 次回以降のログインのときには、転送先のURLはデフォルト(プロフィール画面)に戻っている必要がある。 test/integration/users_edit_test.rb test "successful edit with friendly forwarding" do get edit_user_path(@user) assert_equal session[:forwarding_url], edit_user_url(@user) #ここを追加 log_in_as(@user) assert_nil session[:forwarding_url] #ここを追加 name = "Foo Bar" email = "foo@bar.com" patch user_path(@user), params: { user: { name: name, email: email, password: "", password_confirmation: "" } } assert_not flash.empty? assert_redirected_to @user @user.reload assert_equal name, @user.name assert_equal email, @user.email end end まずは、ログインしていない状態で編集ページにアクセス。 正しい動作ができていれば、beforeフィルターのlogged_in_userによってstore_locationメソッドが実行されsession[:forwarding_url]にedit_user_url(@user)が保存される。 追加した1番目のコードでassert_equalによってそれを確認している。 そしてlog_in_as(@user)によってログインするとSessionsコントローラーのcreateアクションが適用されredirect_back_or @userが実行される。 redirect_back_or @userによってredirect_to(session[:forwarding_url] || default)が実行され、編集画面に移動。さらにsession.delete(:forwarding_url)によってセッションの中身が削除されている。 そしてassert_nil session[:forwarding_url]でセッションの中身が空かを確認している。 (元々記述していた、assert_redirected_to edit_user_url(@user)はredirect_to(session[:forwarding_url] || default)によって既にページ移動が行われているため、削除された。) 長いけど多分こんな感じ。ここら辺になるとメソッドが何をしていたか一個一個確認してるからすごい効率が下がっちゃうなぁ。 2 debuggerメソッドをSessionsコントローラのnewアクションに置く。 その後、ログアウトして /users/1/editにアクセス。 ここでコンソールに移って、session[:forwarding_url]の値が正しいかどうか確認。 省略。 全てのユーザーを表示する(10.3) この節では、indexアクションを追加。 このアクションは、すべてのユーザーを一覧表示さる。 その際、データベースにサンプルデータを追加する方法や、将来ユーザー数が膨大になってもindexページを問題なく表示できるようにするためのユーザー出力のページネーション(pagination=ページ分割)の方法を学ぶ。 ユーザーの一覧ページ(10.3.1) ユーザーの一覧ページを実装するために、まずはセキュリティモデルについて考えてみる。 ユーザーのshowページについては、今後も(ログインしているかどうかに関わらず)サイトを訪れたすべてのユーザーから見えるようにしておくが、ユーザーのindexページはログインしたユーザーにしか見せないようにし、未登録のユーザーがデフォルトで表示できるページを制限する。 indexページを不正なアクセスから守るために、まずはindexアクションが正しくリダイレクトするか検証するテストを書く。 test/controllers/users_controller_test.rb test "should redirect index when not logged in" do get users_path assert_redirected_to login_url end 次に、beforeフィルターのlogged_in_userにindexアクションを追加。 app/controllers/users_controller.rb before_action :logged_in_user, only: [:index, :edit, :update] before_action :correct_user, only: [:edit, :update] def index end 今度はすべてのユーザーを表示するために、全ユーザーが格納された変数を作成し、順々に表示するindexビューを実装する。 User.allを使ってデータベース上の全ユーザーを取得し、ビューで使えるインスタンス変数@usersに代入する。 app/controllers/users_controller.rb def index @users = User.all end 実際のindexページを作成するには、ユーザーを列挙してユーザーごとにliタグで囲むビューを作成する。 ここではeachメソッドを使って作成。 それぞれの行をリストタグulで囲いながら、各ユーザーのGravatarと名前を表示させる。 app/views/users/index.html.erb <% provide(:title, 'All users') %> <h1>All users</h1> <ul class="users"> <% @users.each do |user| %> <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> </li> <% end %> </ul> そして、scssも少し追加。 app/assets/stylesheets/custom.scss /* Users index */ .users { list-style: none; margin: 0; li { overflow: auto; padding: 10px 0; border-bottom: 1px solid $gray-lighter; } } 最後に、サイト内移動用のヘッダーにユーザー一覧表示用のリンクを追加。 app/views/layouts/_header.html.erb <li><%= link_to "Users", users_path %></li> これでindexは動くようになり、テストも全て成功する。 演習 1 レイアウトにあるすべてのリンクに対して統合テストを書いてみる。 test/integration/site_layout_test.rb require 'test_helper' class SiteLayoutTest < ActionDispatch::IntegrationTest test "layout links" do get root_path assert_template 'static_pages/home' assert_select "a[href=?]", root_path, count: 2 assert_select "a[href=?]", help_path assert_select "a[href=?]", about_path assert_select "a[href=?]", contact_path assert_select "a[href=?]", signup_path assert_select "a[href=?]", login_path get contact_path assert_select "title", full_title("Contact") get signup_path assert_select "title", full_title("Sign up") end def setup @user = users(:michael) end test "links when logged in user" do log_in_as(@user) get root_path assert_template 'static_pages/home' assert_template 'static_pages/home' assert_select "a[href=?]", root_path, count: 2 assert_select "a[href=?]", help_path assert_select "a[href=?]", about_path assert_select "a[href=?]", contact_path assert_select "a[href=?]", signup_path assert_select "a[href=?]", users_path assert_select "a[href=?]", user_path(@user) assert_select "a[href=?]", edit_user_path(@user) assert_select "a[href=?]", logout_path end end おそらくこのコードで全てのリンクが試せてるはず。しっかりテストも成功しました。 サンプルのユーザー(10.3.2) この項では、indexページに複数のユーザーを表示させるようにするため、Rubyを使ってユーザーを一気に作成してみる。 そのため、GemfileにFaker gemを追加する。 これは、実際にいそうなユーザー名を作成するgem。 ちなみにfaker gemは普通開発環境以外で使用しないが、今回は例外的に本番環境でも適用させるため、全ての環境で使えるようにする。 Gemfile source 'https://rubygems.org' gem 'rails', '6.0.3' gem 'bcrypt', '3.1.13' gem 'faker', '2.1.2' $ bundle install gemの準備が整ったら、サンプルユーザーを生成するRubyスクリプト(Railsタスクとも呼ばれている)を追加。 Railsではdb/seeds.rbというファイルを標準として使用する。 db/seeds.rb # メインのサンプルユーザーを1人作成する User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar") # 追加のユーザーをまとめて生成する 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!(name: name, email: email, password: password, password_confirmation: password) end 上のコードでは、Example Userという名前とメールアドレスを持つ1人のユーザと、それらしい名前とメールアドレスを持つ99人のユーザーを作成している。 create!は基本的にcreateメソッドと同じだが、ユーザーが無効な場合にfalseを返すのではなく例外を発生させる点が異なる。 こうしておくと見過ごしやすいエラーを回避できるので、デバッグが容易になる。 実際にデータベースをリセットしてRailsタスクを実行する。 $ rails db:migrate:reset $ rails db:seed 実際にサンプルアプリケーションを確認してみるとユーザーが100人に増えている。 ページネーション(10.3.3) 現在、1つのページに大量のユーザーが表示されてしまっている。 これを解決するのがページネーション(pagination)というもので、今回の場合は、1つのページに一度に30人だけユーザーを表示するというもの。 Railsには豊富なページネーションメソッドがある。 今回はその中で最もシンプルかつ堅牢なwill_paginateメソッドを使用。 これを使うためには、Gemfileにwill_paginate gem とbootstrap-will_paginate gemを両方含め、Bootstrapのページネーションスタイルを使ってwill_paginateを構成する必要がある。 まずは各gemをGemfileに追加。 Gemfile source 'https://rubygems.org' gem 'rails', '6.0.3' gem 'bcrypt', '3.1.13' gem 'faker', '2.1.2' gem 'will_paginate', '3.1.8' gem 'bootstrap-will_paginate', '1.0.0' . bundle install ページネーションが動作するには、ユーザーのページネーションを行うようにRailsに指示するコードをindexビューに追加する必要がある。 また、indexアクションにあるUser.allを、ページネーションを理解できるオブジェクトに置き換える必要もある。 まずは、ビューに特殊なwill_paginateメソッドを追加。 app/views/users/index.html.erb <% provide(:title, 'All users') %> <h1>All users</h1> <%= will_paginate %> <ul class="users"> <% @users.each do |user| %> <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> </li> <% end %> </ul> <%= will_paginate %> このwill_paginateメソッドは、usersビューのコードの中から@usersオブジェクトを自動的に見つけ出し、それから他のページにアクセスするためのページネーションリンクを作成している。 ただし、上のビューはこのままでは動かない。 というのも、現在の@users変数にはUser.allの結果が含まれているが、will_paginateではpaginateメソッドを使った結果が必要だから。 必要となるデータの例は次のとおり。 $ rails console >> User.paginate(page: 1) User Load (1.5ms) SELECT "users".* FROM "users" LIMIT 11 OFFSET 0 (1.7ms) SELECT COUNT(*) FROM "users" => #<ActiveRecord::Relation [#<User id: 1,... >> User.paginate(page: 1).length User Load (3.0ms) SELECT "users".* FROM "users" LIMIT ? OFFSET ? [["LIMIT", 30], ["OFFSET", 0]] => 30 paginateでは、キーが:pageで値がページ番号のハッシュを引数に取る。 User.paginateは、:pageパラメーターに基いて、データベースからひとかたまりのデータ(デフォルトでは30)を取り出す。 (上述のコンソール出力結果が30件ではなく11件になっているのは、Active Record自身のコンソール上限によるものですが、lengthメソッドを呼べばこの制約を回避できる。) したがって、1ページ目は1から30のユーザー、2ページ目は31から60のユーザーといった具合にデータが取り出される。 ちなみにpageがnilの場合、 paginateは単に最初のページを返す。 paginateを使うことで、サンプルアプリケーションのユーザーのページネーションを行えるようになる。 具体的には、indexアクション内のallをpaginateメソッドに置き換える。 app/controllers/users_controller.rb def index @users = User.paginate(page: params[:page]) end ここで:pageパラメーターにはparams[:page]が使われていまるが、これはwill_paginateによって自動的に生成されている。 この段階でユーザー一覧ページは完全に動作する。 ユーザー一覧のテスト(10.3.4) ユーザー一覧ページは実際に動くようになったので、ページネーションに対する簡単なテストも書いておく。 今回のテストでは、 ①ログイン ②indexページにアクセス ③最初のページにユーザーがいることを確認 ④ページネーションのリンクがあることを確認 この順番でテストしていく。 最後の2つのステップでは、テスト用のデータベースに31人以上のユーザーがいる必要がある。 そのため、fixtureにユーザーを作成する必要がある。 こちらも手動ではなく、埋め込みRubyを利用して作成する。 test/fixtures/users.yml michael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> archer: name: Sterling Archer email: duchess@example.gov password_digest: <%= User.digest('password') %> lana: name: Lana Kane email: hands@example.gov password_digest: <%= User.digest('password') %> malory: name: Malory Archer email: boss@example.gov password_digest: <%= User.digest('password') %> <% 30.times do |n| %> user_<%= n %>: name: <%= "User #{n}" %> email: <%= "user-#{n}@example.com" %> password_digest: <%= User.digest('password') %> <% end %> 今後必要になるため、2人の名前付きユーザーも一緒に追加している。 これでfixtureファイルができたため、indexページに対するテストを書く。 $ rails generate integration_test users_index invoke test_unit create test/integration/users_index_test.rb 今回のテストでは、paginationクラスを持ったdivタグをチェックして、最初のページにユーザーがいることを確認。 test/integration/users_index_test.rb require 'test_helper' class UsersIndexTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) end test "index including pagination" do log_in_as(@user) get users_path assert_template 'users/index' assert_select 'div.pagination' User.paginate(page: 1).each do |user| assert_select 'a[href=?]', user_path(user), text: user.name end end end このテストが成功すれば完成。 演習 1 ページネーションのリンク(will_paginateの部分)を2つともコメントアウトしてみてテストが失敗するか検証。 失敗した。 2 先ほどは2つともコメントアウトしたが、1つだけコメントアウトした場合、テストが成功してしまうことを確認。 will_paginateのリンクが2つとも存在していることをテストしたい場合は、どのようなテストを追加すれば良いか。 test/integration/users_index_test.rb assert_select "div.pagination", count2 これでOK! パーシャルのリファクタリング(10.3.5) ユーザー一覧ページにページネーションを実装することができましたが、私はここで1つの改良を加えてみたいのです。 とチュートリアル様がおっしゃっていたので改良します。 Railsにはコンパクトなビューを作成するための素晴らしいツールがいくつもあるそう。 の節ではそれらのツールを使って一覧ページのリファクタリングを行う。 ンプルアプリケーションのテストは既に完了しているので、Webサイトの機能を損なうことなく安心してリファクタリングに取りかかれる。 リファクタリングの第一歩は、ユーザーのliをrender呼び出しに置き換えること。 app/views/users/index.html.erb <% provide(:title, 'All users') %> <h1>All users</h1> <%= will_paginate %> <ul class="users"> <% @users.each do |user| %> <%= render user %> <% end %> </ul> <%= will_paginate %> ここでは、renderをパーシャルに対してではなく、Userクラスのuser変数に対して実行している点に注目。 この場合、Railsは自動的に_user.html.erbという名前のパーシャルを探しにいくため、このパーシャルを作成する必要がある。 app/views/users/_user.html.erb <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> </li> ここからさらに改良して、今度はrenderを@users変数に対して直接実行。 app/views/users/index.html.erb <% provide(:title, 'All users') %> <h1>All users</h1> <%= will_paginate %> <ul class="users"> <%= render @users %> </ul> <%= will_paginate %> Railsは@usersをUserオブジェクトのリストであると推測。 さらに、ユーザーのコレクションを与えて呼び出すと、Railsは自動的にユーザーのコレクションを列挙し、それぞれのユーザーを_user.html.erbパーシャルで出力。 これによってコードは極めてコンパクトになった。 ユーザーを削除する(10.4) ユーザー一覧が完成したため、残るはdestroyのみとなった。 これを実装することで、RESTに準拠した正統なアプリケーションとなる。 この節では、ユーザーを削除するためのリンクを追加。 また、削除を行うのに必要なdestroyアクションも実装。 しかしその前に、削除を実行できる権限を持つ管理(admin)ユーザーのクラスを作成。 承認(authorization)においては、このような特権のセットをroleと呼ぶ。 管理ユーザー(10.4.1) 特権を持つ管理ユーザーを識別するために、論理値をとるadmin属性をUserモデルに追加。 こうすると自動的にadmin?メソッド(論理値を返す)も使えるようになるため、これを使って管理ユーザーの状態をテストできる。 いつものようにマイグレーションを実行してadmin属性を追加。 ターミナル上で、この属性の型をbooleanと指定。 $ rails generate migration add_admin_to_users admin:boolean マイグレーションを実行するとadminカラムがusersテーブルに追加される。 db/migrate/[timestamp]_add_admin_to_users.rb class AddAdminToUsers < ActiveRecord::Migration[6.0] def change add_column :users, :admin, :boolean, default: false end end 次に上のようにマイグレーションを編集。 default: falseという引数をadd_columnに追加していることに注目。 これは、デフォルトでは管理者になれないということを示すために設定している。 (default: false引数を与えない場合、 adminの値はデフォルトでnilになるが、これはfalseと同じ意味になるため、必ずしもこの引数を与える必要はない。 ただし、このように明示的に引数を与えておけば、コードの意図をRailsと開発者に明確に示すことができる。) そしてマイグレーションを実行。 $ rails db:migrate そして、Railsコンソールで動作を確認。 $ rails console --sandbox >> user = User.first >> user.admin? => false >> user.toggle!(:admin) => true >> user.admin? => true toggle!メソッドは指定した属性を反転させるメソッド。 そのため、使用後はadmin属性の状態がfalseからtrueに反転した。 仕上げに、最初のユーザーだけをデフォルトで管理者にするようにサンプルデータを更新。 db/seeds.rb # メインのサンプルユーザーを1人作成する User.create!(name: "Example User", email: "example@railstutorial.org", password: "foobar", password_confirmation: "foobar", admin: true) # 追加のユーザーをまとめて生成する 99.times do |n| name = Faker::Name.name email = "example-#{n+1}@railstutorial.org" password = "password" User.create!(name: name, email: email, password: password, password_confirmation: password) end 次にデータベースをリセットして、サンプルデータを再度作成。 $ rails db:migrate:reset $ rails db:seed Strong Parameters、再び 先程は最初のユーザーだけをデフォルトで管理者にした。 ここでは、荒れ狂うWeb世界にオブジェクトをさらすことの危険性を改めて強調するらしい。 もし、任意のWebリクエストの初期化ハッシュをオブジェクトに渡せるとなると、攻撃者は次のようなPATCHリクエストを送信してくる可能性がある。 patch /users/17?admin=1 このリクエストは、17番目のユーザーを管理者に変えてしまうことができる。 ユーザーのこの行為は、少なくとも重大なセキュリティ違反となる可能性があり、実際にはそれだけでは済まない。 このような危険があるからこそ、編集してもよい安全な属性だけを更新することが重要。 具体的には、Strong Parametersを使って対策します。 次のようにparamsハッシュに対してrequireとpermitを呼び出す。 def user_params params.require(:user).permit(:name, :email, :password, :password_confirmation) end 上のコードでは、許可された属性リストにadminが含まれていない。 これにより、任意のユーザーが自分自身にアプリケーションの管理者権限を与えることを防止できる。 この問題は重大であるため、編集可能になってはならない属性に対するテストを演習で作成する。 演習 1 Web経由でadmin属性を変更できないことを確認。 具体的には、PATCHを直接ユーザーのURL(/users/:id)に送信するテストを作成する。 テストが正しい振る舞いをしているかどうか確信を得るために、まずはadminをuser_paramsメソッド内の許可されたパラメータ一覧に追加する。 test/controllers/users_controller_test.rb test "should not allow the admin attribute to be edited via the web" do log_in_as(@other_user) assert_not @other_user.admin? patch user_path(@other_user), params: { user: { password: "password", password_confirmation: "password", admin: FILL_IN } } assert_not @other_user.FILL_IN.admin? end これによってリクエストが例え送られても保存されていないことが確認できた。 destroyアクション(10.4.2) Usersリソースの最後の仕上げとして、destroyアクションへのリンクを追加する。 まず、ユーザーindexページの各ユーザーに削除用のリンクを追加し、続いて管理ユーザーへのアクセスを制限する。 これによって、現在のユーザーが管理者のときに限り [delete] リンクが表示されるようになる。 app/views/users/_user.html.erb <li> <%= gravatar_for user, size: 50 %> <%= link_to user.name, user %> <% if current_user.admin? && !current_user?(user) %> | <%= link_to "delete", user, method: :delete, data: { confirm: "You sure?" } %> <% end %> </li> 必要なDELETEリクエストを発行するリンクの生成は、method: :deleteによって行われている点に注意。 また、各リンクをif文で囲い、管理者にだけ削除リンクが表示されるようにしている。 ブラウザはネイティブではDELETEリクエストを送信できないため、RailsではJavaScriptを使って偽造している つまり、JavaScriptがオフになっているとユーザー削除のリンクも無効になるということ。 JavaScriptをサポートしないブラウザをサポートする必要がある場合は、フォームとPOSTリクエストを使ってDELETEリクエストを偽造することもできるらしい。 実はこのコードの意味が分からなくて3時間ぐらい悩みました。 ⇦馬鹿すぎ 特に!current_user?(user)が本当に難しかった。 自分の解釈としてはこんな感じ。 まずはif current_user.admin?でログインしているユーザーが管理者権限を持っているか 次に!current_user?(user)でeach doで処理しているuserがログインしているユーザーではないかを確認している。 一番最初に思ったのはなぜif current_user.admin?だけじゃだめなのか。 今ログインしているユーザーが管理者権限を持っているなら削除ボタンを出すだけで良くない? それなら!current_user?(user)いらないじゃん。 この疑問の答えは!current_user?(user)はログインしている自分自身のユーザは消せないようにするためのコードということ。 !current_user?(user)の有無で何が変わるかを考えてみる。 &&が使われているから左右どちらもtrueじゃないといけない。 この場合を言葉で表してみるとログインしているユーザーが管理者権限を持っていてさらにeach doで処理しているuserが自分自身でないこととなる。 このことから逆に「each doで処理しているuserが自分自身だとボタンを表示させない」ということが分かる。 これが私が自分で納得した答えです。 あまりにも分からなかったので紙にまとめてみたらやっと理解できました。 いざというときは実際に何かに書いて考えてみるっていう重要さに気づけて良かった! こちら参考にさせていただいたサイトです。 if current_user.admin? && !current_user?(user)で「!」が必要な理由 - Qiita それではチュートリアルに戻ります。 これで管理者にボタンが表示されるようになったが、この削除リンクが動作するためには、destroyアクションを追加する必要がある。 このアクションでは、該当するユーザーを見つけてActive Recordのdestroyメソッドを使って削除し、最後にユーザーindexに移動させる。 ユーザーを削除するためにはログインしていないといけないため、destroyアクションもlogged_in_userフィルターに追加する。 app/controllers/users_controller.rb class UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy] before_action :correct_user, only: [:edit, :update] . . . def destroy User.find(params[:id]).destroy flash[:success] = "User deleted" redirect_to users_url end これで結果として、管理者だけがユーザーを削除できるようになる。 (体的には、削除リンクが見えているユーザーのみ削除できる。) しかし、実はまだ大きなセキュリティホールがあるらしい。 ある程度の腕前を持つ攻撃者なら、コマンドラインでDELETEリクエストを直接発行するという方法でサイトの全ユーザーを削除してしまうことができる。というもの。 サイトを正しく防衛するには、destroyアクションにもアクセス制御を行う必要がある。 これを実装してようやく、管理者だけがユーザーを削除できるようにする。 今回はbeforeフィルターを使ってdestroyアクションへのアクセスを制御する。 app/controllers/users_controller.rb class UsersController < ApplicationController before_action :logged_in_user, only: [:index, :edit, :update, :destroy] before_action :correct_user, only: [:edit, :update] before_action :admin_user, only: :destroy . . . private . . . # 管理者かどうか確認 def admin_user redirect_to(root_url) unless current_user.admin? end end 今回は、admin_userフィルターを作成。 これでdestroyアクションは完成した。 ユーザー削除のテスト ユーザーを削除するといった重要な操作については、期待された通りに動作するか確かめるテストを書くべき。 そこで、まずはユーザー用fixtureファイルを修正し、今いるサンプルユーザーの一人を管理者にしてみる。 test/fixtures/users.yml michael: name: Michael Example email: michael@example.com password_digest: <%= User.digest('password') %> admin: true Usersコントローラをテストするために、アクション単位でアクセス制御をテストする必要がある。 ログアウトのテストと同様に、削除をテストするために、DELETEリクエストを発行してdestroyアクションを直接動作させる。 このとき2つのケースをチェック。 1つは、ログインしていないユーザーであれば、ログイン画面にリダイレクトされること。 もう1つは、ログイン済みではあっても管理者でなければ、ホーム画面にリダイレクトされること。 test/controllers/users_controller_test.rb require 'test_helper' class UsersControllerTest < ActionDispatch::IntegrationTest def setup @user = users(:michael) @other_user = users(:archer) end . . . test "should redirect destroy when not logged in" do assert_no_difference 'User.count' do delete user_path(@user) end assert_redirected_to login_url end test "should redirect destroy when logged in as a non-admin" do log_in_as(@other_user) assert_no_difference 'User.count' do delete user_path(@user) end assert_redirected_to root_url end end この場面では、assert_no_differenceメソッドを使って、ユーザー数が変化しないことを確認している。 なお上記のテストでは、管理者ではないユーザーの振る舞いについて検証しているが、管理者ユーザーの振る舞いと一緒に確認できるとなお良い。 そこで、管理者であればユーザー一覧画面に削除リンクが表示される仕様を利用して、テストに今回のテストを追加してみる。 これにより、後ほど追加する管理者の振る舞いについても簡単にテストが書けるそう。 今回のテストで唯一の手の込んだ箇所は、管理者が削除リンクをクリックしたときに、ユーザーが削除されたことを確認する部分。 それがこのコード。 assert_difference 'User.count', -1 do delete user_path(@other_user) end 7章では、assert_differenceメソッドを使ってユーザーが作成されたことを確認していたが、今回は同じメソッドを使ってユーザーが削除されたことを確認している。 具体的には、DELETEリクエストを適切なURLに向けて発行し、User.countを使ってユーザー数が 1減ったかどうかを確認。 したがって、管理者や一般ユーザーのテスト、そしてページネーションや削除リンクのテストをすべてまとめると、以下のようになる。 test/integration/users_index_test.rb class UsersIndexTest < ActionDispatch::IntegrationTest def setup @admin = users(:michael) @non_admin = users(:archer) end test "index as admin including pagination and delete links" do log_in_as(@admin) get users_path assert_template 'users/index' assert_select 'div.pagination' first_page_of_users = User.paginate(page: 1) first_page_of_users.each do |user| assert_select 'a[href=?]', user_path(user), text: user.name unless user == @admin #adminの時は削除が表示されないから。 assert_select 'a[href=?]', user_path(user), text: 'delete' end end assert_difference 'User.count', -1 do delete user_path(@non_admin) end end test "index as non-admin" do log_in_as(@non_admin) get users_path assert_select 'a', text: 'delete', count: 0 end end このテストでは、各ユーザーの削除リンクをテストするときに、ユーザーが管理者であればスキップしている点にも注目。(これは、管理者であれば削除リンクが表示されないから。) これでいつも通りgitとherokuにpushしてこの章終了! フレンドリーフォワーディングフレンドリーフォワーディングというのは、ログインが必要なページ(仮にAページとします)に未ログイン状態でアクセスした場合に、ログイン画面に遷移させてログインした後はAページに戻すというものです。フレンドリーフォワーディングを実装 | 株式会社ランチェスター ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RSpecをインストール

【RSpecをインストール】 Dockerで開発したRailsアプリのテストコードを書きます。 RSpecを使用するのでインストール手順を備忘録として残します。 RSpecをインストール Gemfileに下記のように追記し、 $ docker-compose build を実行する。 Gemfile group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console gem 'rspec-rails', '~> 5.0.0' end 次に、$ docker-compose run web bundle exec rails g rspec:install を実行し、RSpecに必要なディレクトリや設定ファイルを作成する。 //実行結果 Running via Spring preloader in process 26 create .rspec create spec create spec/spec_helper.rb create spec/rails_helper.rb ※ spec/spec_helper.rb は、全体的な設定を記述するファイル ※ spec/rails_helper.rb は、Rails特有の設定を記述するファイル
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FactoryBotのアソシエーション

terminal Failures: 1) Item#create image,item_name,description,category_id,status_id,prefecture_id,delivery_fee_payment_id,delivery_prepare_id,priceの値が存在すれば登録できること Failure/Error: expect(@item).to be_valid expected #<Item id: nil, item_name: "b21ywo", description: "frec268o37r2ulb917hgti5vwbf7hbt07sxp05ezpt8ad2z1iv...", category_id: 2, status_id: 4, delivery_fee_payment_id: 2, prefecture_id: 44, delivery_prepare_id: 4, price: 4723291, user_id: nil, created_at: nil, updated_at: nil> to be valid, but got errors: User must exist # ./spec/models/item_spec.rb:11:in `block (3 levels) in <top (required)>' エラー文[but got errors: User must exist]とは ユーザー情報がないよっといっています。 解説 条件 モデル名 組んでいるアソシエション 外部キー ①アソシエーション user has_many :item なし ②外部キーを保持 item belongs_to :user あり 解答 上記の中でitemモデルのテストを行う場合は外部キーを保持しているモデルのテストのファクトリーボットにアソシえーション設定が必要になってきます。 ruby FactoryBot.define do factory :item do # image { Rack::Test::UploadedFile.new("#{Rails.root}/spec/fixtures/20210327-085606.png") } item_name {Faker::Lorem.characters(number: 6, min_alpha: 1, min_numeric: 1) } description {Faker::Lorem.characters(number: 100, min_alpha: 1, min_numeric: 1) } category_id {Faker::Number.between(from: 2, to: 11)} # 2〜11のいずれかがランダムに表示 status_id {Faker::Number.between(from: 2, to: 6)} # 2〜6がランダムに表示 prefecture_id {Faker::Number.between(from: 2, to: 48)} # 2〜48がランダムに表示 delivery_fee_payment_id {Faker::Number.between(from: 2, to: 3)} # 2〜3がランダムに表示 delivery_prepare_id {Faker::Number.between(from: 2, to: 4)} # 2〜4のいずれかがランダムに表示 price {Faker::Number.between(from: 1, to: 9999999)} # 1〜9999999のいずれかがランダムに表示 association :user end end
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ナビゲーションメニューの色を随時変更する方法

※プログラミング初学者の方用に記事を書いています。 自分が遭遇したエラーなどに同じように遭遇するかもなのでご参考になれば そもそもナビゲーションメニューって何? ナビゲーションメニューとは、サイトやブログの上部やサイドなどに並んでいるリンクのことです。 ※bootstrapの公式サイト↓ たとえば上記の画像だと、自分は「ホーム」のページにいるので 「ホーム」のところが白くなっています。 こんな感じで、サイト上で自分が今いる項目の部分の色を変えるための方法を解説していきます! 完成品の画像 左のナビゲーションバーに注目! ※bootstrapが入っている前提 解説 上記の画像のように色をつけるためには、自分のいるリンクに 「'nav-link active'」というclassをつけることで実装できます。 つまり、「掲示板一覧」にいるときは、そこに対応するリンクのclassに対して「active」を付与し、「ユーザー一覧」にいるときは、そこに対応するリンクのclassに対して「active」を付与するというようにします。 ※実装する前のコード _sidebar.html.erb <!-- Sidebar Menu --> <nav class="mt-2"> <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false"> <li class="nav-item"> <%= link_to admin_boards_path, class:"nav-link" do %> <i class="nav-icon far fa-file"></i> <p> 掲示板一覧 </p> <% end %> </li> <li class="nav-item"> <%= link_to admin_users_path, class:"nav-link" do %> <i class="nav-icon far fa-user"></i> <p> ユーザー一覧 </p> <% end %> </li> </ul> </nav> <!-- /.sidebar-menu --> 上記の「link_to」の部分のclassに、状況に応じて'active'というclassを付与するためのヘルバーメソッドを定義します。 helpers/application_helper.rb def active_class(controller_name) controller_name == params[:controller]? 'active' : '' end ※三項演算子なので下記とイコール def active_class(controller_name) return 'active' if controller_name == params[:controller] end 上記のように、引数としてコントローラの名前を渡し、 それがparamsに入っているcontrollerと一致していれば'active'を返すようにします。 「掲示板一覧」をクリックしたときは、'boards'コントローラのindexアクションが動き、 「ユーザー一覧」をクリックしたときは、'users'コントローラが動くので、 状況に応じて'active'を与えることができます。 先程のビューに、定義したメソッドを呼び出すための記述をします。 _sidebar.html.erb <!-- Sidebar Menu --> <nav class="mt-2"> <ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false"> <li class="nav-item"> <%= link_to admin_boards_path, class:"nav-link #{active_class("boards")}" do %> <i class="nav-icon far fa-file"></i> <p> 掲示板一覧 </p> <% end %> </li> <li class="nav-item"> <%= link_to admin_users_path, class:"nav-link #{active_class("users")}" do %> <i class="nav-icon far fa-user"></i> <p> ユーザー一覧 </p> <% end %> </li> </ul> </nav> <!-- /.sidebar-menu --> 引数には、対応するコントローラの名前を渡します。 これで完成! ※間違いなどあればコメントください!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】graphql-rubyでのオフセットベースのページネーションの実装方法【Graphql】

要件からカーソルベースではなくオフセットベースのページネーションを作らないといけなかったがスムーズにいかなかったので当時のメモ。先輩にも助けて頂いた。 使用技術は フロントエンド - nuxt + vuetify バックエンド - rails + Graphql という組み合わせ 実現方法としては - カーソルベースで一度頭から該当ページのカーソルIDを取ってそれを元に1ページの個数をとるという2度APIを取る方法 - graphql-rubyにコードを追加してoffsetやtotal countを返す方法 の二つ考えられるが今回は後者を書く。 app/graphql/offset_extension.rb class OffsetExtension < GraphQL::Schema::Field::ConnectionExtension def apply super field.argument :offset, 'Int', 'Offset value.', required: false end def after_resolve(args) offset = args[:memo][:offset] new_args = offset ? args.merge(value: args[:value].offset(offset)) : args super(new_args) end end app/graphql/types/base_field.rb module Types class BaseField < GraphQL::Schema::Field argument_class Types::BaseArgument connection_extension(::OffsetExtension) end end app/graphql/types/pagination_connection.rb module Types class PaginationConnection < GraphQL::Types::Relay::BaseConnection field :total_count, Int, null: false def total_count object.items.size object.items.unscope(:offset).unscope(:limit).count end end end 参考 GraphQL RubyのPaginationについて|Daiki Tanaka|note GraphQLでのPaginationの実装方法について(for ruby) - Qiita graphql-rubyでページネーションがサクッと実装できたのでGemが何をやっているのか覗いてみた - Qiita RailsでGraphQL APIを作る時に悩んだ5つのこと | スペースマーケットブログ Offset based pagination in GraphQL-ruby - Blog by Abhay Nikam Introduction to pagination in GraphQL
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

勤怠管理SPAサイトをリリースしてみた【個人開発】

サービスについて https://kintai-kantan.com?params=introduction が紹介ページとなります。 まだ、試作段階で、何かと不具合があるかと思いますが、使っていただければ幸いです。 作ろうと思ったきっかけ 業務で React と Ruby on Railsを扱うことになりました。 しかし、これらに知見がないので、勉強がてら、なにかのサービスを作成し、リリースしてみようと思い、行動にいたりました。 そして、まず思いついたのが勤怠入力サービスでした。 なぜなら、普段使っているものが、遅いし、入力する項目が多くて使い辛いし、なんとかならないものかと不満が溜まっていたから。 サービスの思想 とにかく、処理をシンプルにする! はやい!! 使用技術 ざっくりとになりますが、 バックエンド Ruby on Rails Graph QL データベース PostgreSQL フロントエンド React TypeScript サーバーサイド さくらVPS docker-compose nginx https-portal 感想 はじめて、Qiitaに投稿してみました。 また、投稿したいと思います。 みていただき、ありがとうございます。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

「Error: Undefined method `authenticates_with_sorcery!' for User:Class」の対処法

※プログラミング初学者の方用に記事を書いています。 自分が遭遇したエラーなどに同じように遭遇するかもなのでご参考になれば sorceryを導入しようとしたときに遭遇したエラー 公式のwiki通りにGemfileに'sorcery'と記載して下記コマンドを実行。 $ rails g sorcery:install $ rails db:migrate その際にこんなエラーに遭遇。 Error: Undefined method `authenticates_with_sorcery!' for User:Class まあ簡単に訳すと Userクラスに対して`authenticates_with_sorcery!'なんてメソッドは 定義されてないので使えませんよー! みたいな感じです!笑 `authenticates_with_sorcery!'とはなんぞやって話なんですけど、 sorceryをインストールするとUserモデルやマイグレーションファイルが生成されます。 その際に、Userモデルに先程の`authenticates_with_sorcery!'メソッドが定義されます。 models/user.rb class User < ApplicationRecord authenticates_with_sorcery! end このメソッドをUserモデルに定義することで、 認証機能などのsorceryの機能がもろもろ使えるようになります。 なので、sorceryをインストールした時点で勝手に定義されるので、こんなエラーが出るのがおかしいわけなんです笑 そこでuser.rbを確認してみたものの、やはりしっかりと定義されていたんです! あれ?っとなるわけです。 そこで生成されたマイグレーションファイルを確認してみたところ、、、 class SorceryCore < ActiveRecord::Migration[5.2] def change create_table :User do |t| t.string :email, null: false t.string :crypted_password t.string :salt t.timestamps null: false end add_index :User, :email, unique: true end end create_tableのところが「User」になっていたわけです。 通常、generateコマンドでUserモデルを作成すると こんな感じのマイグレーションファイルになると思います。 $ rails g model User name:string email:string class CreateUsers < ActiveRecord::Migration[5.2] def change create_table :users do |t| t.string :name t.string :email t.timestamps end end end Userテーブルではなく、usersテーブルが作成されるはずなんです。 つまり今回のエラーの原因はここです。 なので、先程のマイグレーションファイルの 「User」のところを「users」に変えてあげれば、 class SorceryCore < ActiveRecord::Migration[5.2] def change create_table :users do |t| t.string :email, null: false t.string :crypted_password t.string :salt t.timestamps null: false end add_index :users, :email, unique: true end end $ rails db:migrate 問題なくマイグレートできます! ※間違いなどあればコメントください!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RSpec2回目商品出品機能テスト

itemモデルのバリデーションを書こうと思います。 1)bundle install(済) 2)rails g rspec:install(済) 3).rspec・rspecフォルダ・spec/spec_helper.rb・rails_helper.rbの各フォルダあり。(済) 4)itemモデルのテストファイル作成(ここから) ここでエラー発生 terminal maedatakuo@maedatakudainoMacBook-Air furima-32844 % rails g rspec:model item Running via Spring preloader in process 16513 identical spec/models/item_spec.rb invoke factory_bot identical spec/factories/items.rb に怒られました。 解答 RspecとFactoryBotがインストールされるとモデルを作るたびにファイルが自動生成されているようでした。 Userの時のデータを活用しながらテスト作り再開 疑問1 ActiveHashの1〜6までのデータで登録するんだけどFakerで数字限定で乱数出せる? 確認できました 参考 https://www.rubydoc.info/github/stympy/faker/Faker%2FNumber.between ruby Faker::Number.between(from: 1, to: 10) #=> 7 疑問2 ActiveImageを使いながらFakerやFactoryBotで画像をアップロードできるのか? @maca12velさん ありがとうございます。参考にさせていただきました。 https://qiita.com/maca12vel/items/ee4d16827f24f69080ae *私の場合は「image」だったので@Katyaさん(@katya_swaさん)の使用している「picture」は「image」に変更しています。 ①fixturesディレクトリの作成 1-1.spec内にfixturesディレクトリがない場合は、作成する。 1-2.使いたい画像ファイルをfixturesディレクトリの中に入れる。 1-3.spec/fixturesの中に20210327-085606.pngという画像ファイルを入れています。 1-4.以下を記述 spec/models/item.rb require 'rails_helper' RSpec.describe Item, type: :model do describe '#create' do before do @item = FactoryBot.build(:item) @item.image = fixture_file_upload("/20210327-085606.png") end 疑問3 imageのテストが通らない Item #create image,item_name,description,category_id,status_id,prefecture_id,delivery_fee_payment_id,delivery_prepare_id,priceの値が存在すれば登録できること imageが空では登録できないこと (FAILED - 1) item_nnameが空では登録できないこと descriptionが空では登録できないこと catego_idが空では登録できないこと status_idが空では登録できないこと delivery_fee_payment_idが空では登録できないこと delivery_prepare_idが空では登録できないこと prefecture_idが空では登録できないこと prefecture_idが空では登録できないこと priceは全角カナでは登録できないこと Failures: 1) Item#create imageが空では登録できないこと Failure/Error: @item.valid? ActiveSupport::MessageVerifier::InvalidSignature: ActiveSupport::MessageVerifier::InvalidSignature # ./spec/models/item_spec.rb:16:in `block (3 levels) in <top (required)>' Finished in 0.24549 seconds (files took 1.21 seconds to load) 11 examples, 1 failure Failed examples: rspec ./spec/models/item_spec.rb:14 # Item#create imageが空では登録できないこと ただいま調査中 結論からいうとActive Strageの使用でからという表現を””ではなくnilとしないといけないらしい (誤) spec/models/item.rb it 'imageが空では登録できないこと' do @item.image = "" @item.valid? expect(@item.errors.full_messages).to include("Image can't be blank") end (正) spec/models/item.rb it 'imageが空では登録できないこと' do @item.image = nil @item.valid? expect(@item.errors.full_messages).to include("Image can't be blank") end 参考:@a1b2c3d4e5f1111さんありがとうございます。参考にします。 https://qiita.com/a1b2c3d4e5f1111/items/85817cdb8ca8544b88ea
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Rails]大変便利なFormオブジェクトはご存知でしょうか?

はじめに 突然ですが、「Formオブジェクト」というものを知っていますでしょうか? 便利なものなのですが、意外と使われていない人もいるかと思います。 これを知ってることで、開発がよりスムーズに進無こと間違いなしです!! Formオブジェクトとは? モデルとフォームの責務を切り分けられる事で、単体のモデルに依存しない場合や、フォーム専用の特別な処理をモデルに書きたくない場合に用いたりします。 Railsのフォームは基本的にモデルに依存しています。例えば1つのフォーム送信で複数のモデルの更新をしたい場合バリデーションの責務が曖昧なものとなり、可読性も低下するため、責務を明確にするということで使います。 1つのフォームで複数モデルの操作をしたいときにForm Objectを使うと、処理がすっきりかける。またログインに関する処理など、特定のフォームでしか行わない処理もForm Objectに書くと良い。 メリット modelとformで入力値の検証かビジネスロジックの検証かを分別することができる。 一度のフォーム送信時に複数の ActiveRecordモデルを更新しやすくできる。 同じ処理をmodelに対して行う際にForm層の再利用が可能になる。 ActiveRecordと同じバリデーションを使うことができる。 Formオブジェクトにパラメーターを渡すことができるためエラーメッセージを戻す処理が書きやすい。 ビジネスロジックをcontroller/viewから切り離せる(単一責務の状態)。 ユースケース ユーザからの問い合わせフォームの実装 1つの記事に複数の画像を保存する処理(has_manyな関係) ユーザーのログイン処理 APIのパラメーターに対するバリデーション 使い方 1.appディレクトリにformsディレクトリを作成 $ mkdir app/forms 2.利用したいフォームのFormオブジェクトを作成 $ touch app/forms/sample_form.rb 3.作成したファイルに、バリデーションや使用するメソッドを記述していく。 app/forms/sample_form.rb class SampleForm include ActiveModel::Model # 通常のモデルのようにvalidationなどを使えるようにする include ActiveModel::Attributes # ActiveRecordのカラムのような属性を加えられるようにする attribute :first_name, :string attribute :last_name, :string attribute :email, :string attribute :body, :string validates :first_name, presence: true validates :last_name, presence: true validates :email, presence: true validates :body, presence: true def save 処理 end end コントローラーにも記述する。 class SamplesController < ApplicationController ... def new @sample = SampleForm.new end def create @sample = SampleForm.new(sample_params) if @sample.save redirect_to :samples, notice: 'サンプル情報を作成しました。' else render :new end end private def sample_params params.require(:sample).permit(:first_name, :last_name, :email, :body) end end 終わりに 開発の規模が大きくなるほどに、コントローラーやモデルは膨大になり、ごちゃごちゃしてきます。 そういう時に、処理ごとに切り分けてまとめることは可読性も上がってとても重要なことです。 状況を見て利用してみても良いかなと思います! 参考 【Rails】FormObjectを使ってほしい RailsのForm Objectについて 【Rails】Form Objectを使ってModelに依存しないFormを作成する 【Ruby on Rails】フォームオブジェクトを使って検索キーワードをcontrollerに送る
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails indexページでeach文書いたら、変な表示になった

railsでindex表示に失敗した話 以下のようなブラウザ表示になった 本当は、こうしたい 解決法 index.html.erb <table> <thead> <tr> <td>Title</td> <td>Opinion</td> </tr> </thead> <tbody> <% @books.each do |b| %> <tr> <td><%= link_to b.title, book_path(b) %></td> <td><%= b.body %></td> </tr> <% end %> </tbody> </table> <% @books.each do |b| %>でなく<%= @books.each do |b| %>のように=をつけていたため変な表示になっていた 間違いあれば教えてください。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Railsアプリに非同期通信のいいね機能を実装

はじめに Rialsアプリに非同期通信のいいね機能を実装しました。 方針としては、部分テンプレートを作成してレスポンスをJS形式で返すことで画面の一部のみを更新するというものです。備忘録としてまとめたいと思います。 実装イメージ 非同期でいいねといいねの解除ができる いいねされたらカウントに反映される 開発環境 macOS Catalina Ruby 2.6.5 Ruby on Rails 5.2 前提 userとpost(投稿)モデルを作成済み fontawesomeを導入済み jQueryを導入済み 目次 1.モデルとアソシエーション 2.ルーティング 3.コントローラー 4.view 1. モデルとアソシエーション モデルとアソシエーションの構成は以下の通りです。 ではfavoriteモデルを作成します。 ターミナル $ rails g model favorite user:references post:references $ rails db:migrate 次にアソシエーションを組みます。 user.rb has_many :posts, dependent: :destroy has_many :favorites, dependent: :destroy # すでにいいねしているかを判定するメソッド def already_favorited?(post) self.favorites.exists?(post_id: post.id) end already_favorited?メソッドを定義します。 これはもし投稿にいいねしていたら解除のリンクを表示させ、解除していたらいいねリンクを表示させる条件分岐のために記述しています。 post.rb belongs_to :user has_many :favorites, dependent: :destroy favorite.rb belongs_to :user belongs_to :post 2. ルーティング いいねは投稿ひとつひとつに紐付いているのでルーティングはネストさせて記述します。 ネストさせることでアソシエーション先のレコードのidをparamsに追加してコントローラーに渡せるようになります。 (今回の場合はfavoriteのidを取得できる) routes.rb resources :posts, only: [:index, :new, :create, :show, :destroy] do resource :favorites, only: [:create, :destroy] end 3. コントローラー favorites_controller.rbではcreateとdestroyアクションを定義します。 favorites_controller.rb class FavoritesController < ApplicationController before_action :set_post def create @favorite = Favorite.create(user_id: current_user.id, post_id: @post.id) @favorite.save end def destroy @favorite = Favorite.find_by(user_id: current_user.id, post_id: @post.id) @favorite.destroy end private def set_post @post = Post.find(params[:post_id]) end end set_post ・before_actionを設定することで、アクション実行前にどの投稿に対するものなのかを判断するためにidを取得します。 posts_controller.rb def show @post= Post.find(params[:id]) end 4. view 非同期通信をするために_favorite.html.erbという部分テンプレートを作成します。 更にcreateとdestroyアクション実行時にページの一部を更新するためにcreate.js.erbとdestroy.js.erbというファイルを作成します。 JS形式でレスポンスするためにjs.erbという拡張子になっています。 ディレクトリ構成は以下の通りです。 views |-posts | |-show.html.erb |-favorites |-_favorite.html.erb |-create.js.erb |-destroy.js.erb まずは、投稿詳細ページのviewから記述します。 posts/show.html.erb <div id="favorite_area_<%= post.id %>", class="favoriteArea"> <%= render partial: "favorites/favorite", locals: { post: @post } %> </div> divタグにfavorite_area_<%= post.id %>というidを付与しています。 これはどの投稿に対するものなのかを判別するために記述しています。 またrenderメソッドで_favorite.html.erbファイルを呼び出しています。 localsオプションではpostコントローラーで定義した@postの変数を部分テンプレートのなかでpostとして使用できるように定義しています。 次に部分テンプレートです。 favorites/_favorite.html.erb <% if current_user.already_favorited?(post) %> <%= link_to post_favorites_path(post), method: :delete, class: "goodLink", remote: true do %> <i class="fas fa-heart"></i> <% end %> <% else %> <%= link_to post_favorites_path(post), method: :post, class: "goodLink", remote: true do %> <i class="far fa-heart"></i> <% end %> <% end %> <p class="favoriteCount"><%= post.favorites.count %></p> user.rbで定義したalready_favorited?メソッドを使用しています。 link_toにはremote: tureを記述することで非同期通信をしますという意味になります。これでHTML形式ではな先程作成したjs.erbファイルを返す挙動になります。 最後の行の<%= post.favorites.count %>はcountメソッドを使用していいねされている数を表示しています。 最後にjs.erbファイルの編集です。 favorites/create.js.erb <!-- #favorite_area_<%= @post.id %>この部分のHTMLだけ、renderで部分的に更新するという処理 --> $("#favorite_area_<%= @post.id %>").html("<%= j(render partial: 'favorites/favorite', locals: { post: @post }) %>"); favorites/destroy.js.erb <!-- #favorite_area_<%= @post.id %>この部分のHTMLだけ、renderで部分的に更新するという処理 --> $("#favorite_area_<%= @post.id %>").html("<%= j(render partial: 'favorites/favorite', locals: { post: @post }) %>"); 意味としてはfavorite_area_<%= @post.id %>というidを付与したdivタグの中身を_favorite.html.erbの内容で更新するという内容になります。 以上で非同期のいいね機能を実装できていると思います!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Rails]N+1問題の解決策

問題が発生している様子 画面を読み込んでいるときのターミナル Terminal Processing by PagesController#index as HTML Rendering layout layouts/application.html.slim Rendering pages/index.html.slim within layouts/application Product Load (0.9ms) SELECT `products`.* FROM `products` ↳ app/views/pages/index.html.slim:25 Brand Load (0.9ms) SELECT `brands`.* FROM `brands` WHERE `brands`.`id` = 1 LIMIT 1 ↳ app/views/pages/index.html.slim:30 Brand Load (0.9ms) SELECT `brands`.* FROM `brands` WHERE `brands`.`id` = 12 LIMIT 1 ↳ app/views/pages/index.html.slim:30 Brand Load (0.8ms) SELECT `brands`.* FROM `brands` WHERE `brands`.`id` = 15 LIMIT 1 ↳ app/views/pages/index.html.slim:30 Brand Load (0.9ms) SELECT `brands`.* FROM `brands` WHERE `brands`.`id` = 2 LIMIT 1 ↳ app/views/pages/index.html.slim:30 Brand Load (0.8ms) SELECT `brands`.* FROM `brands` WHERE `brands`.`id` = 6 LIMIT 1 ↳ app/views/pages/index.html.slim:30 Brand Load (0.8ms) SELECT `brands`.* FROM `brands` WHERE `brands`.`id` = 3 LIMIT 1 ↳ app/views/pages/index.html.slim:30 Rendered pages/index.html.slim within layouts/application (Duration: 44.1ms | Allocations: 14640) 複数のクエリが発行されている。 最初にproductsテーブル中の全てを取り出すのに1回、その中からbrandを6回取り出している。 これは今後クエリ発生数が、何万回のように膨大になると動作が悪化するので対策をとる。 解決策 Controllerで、includesメソッドを使用する。(N+1問題が発生している箇所) 自分はallメソッドを使用していた。 app/controllers/pages_controller.rb class PagesController < ApplicationController def index - @products = Product.all + @products = Product.includes(:brand) end end includeメソッド基礎構文 モデル名.include(:関連名) 関連名 ≠ テーブル名 (下記 app/models/product.rb 参照) 対策した結果 Terminal Processing by PagesController#index as HTML Rendering layout layouts/application.html.slim Rendering pages/index.html.slim within layouts/application Product Load (0.9ms) SELECT `products`.* FROM `products` ↳ app/views/pages/index.html.slim:25 Brand Load (1.1ms) SELECT `brands`.* FROM `brands` WHERE `brands`.`id` IN (1, 12, 15, 2, 6, 3) ↳ app/views/pages/index.html.slim:25 Rendered pages/index.html.slim within layouts/application (Duration: 24.3ms | Allocations: 10596) Brandに関するクエリの発生数が一回になった。 その他詳細 コード View index.html.slim app/views/pages/index.html.slim .container .row .col-12 table.table.table-hover.table--index-product thead (省略) tbody - @products.each do |product| tr th scope="row" td p = link_to product.name, product p = link_to product.brand.name, product.brand td.d-none.d-md-table-cell p = link_to product.battery_capacity, product td p = link_to product.soc_antutu_score, product Model ER図 複数のProductは、一つのBrandに属している。 Productテーブルに対して、Brandは必ず1レコード存在させる。 app/models/brand.rb class Brand < ApplicationRecord has_many :products end app/models/product.rb class Product < ApplicationRecord belongs_to :brand #関連名 validates :brand_id, presence: true end ・belongs_toメソッドの引数の関連名は単数形 Controller Pages_controller.rb class PagesController < ApplicationController def index @products = Product.includes(:brand) end end 参考URL includesメソッド Active Record クエリインターフェイス - Railsガイド 【Rails】N+1問題をincludesメソッドで解決しよう! | Pikawaka - ピカ1わかりやすいプログラミング用語サイト ER図 若手プログラマー必読!5分で理解できるER図の書き方5ステップ 【Rails】アソシエーションを図解形式で徹底的に理解しよう! | Pikawaka - ピカ1わかりやすいプログラミング用語サイト
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ActiveHash を使って記事に紐付けた都道府県データを元に、地方別の投稿一覧を作る

前提 自作のアプリケーションで文化施設の情報を投稿できるものを作っていて、 投稿時に入力した都道府県のデータを元に、地方ごとで一覧表示できるようにしました。 そもそも投稿の時に地方の情報も一緒に登録するとか、 地方ごとに同じようなコードを書いているのであまりよろしくないとか改善の余地はたくさんあるとは思いますが、 とりあえず動かすことはできたという記録のために記事を作成します。 都道府県データ prefecture.rb class Prefecture < ActiveHash::Base self.data = [ { id: 1, name: '---' }, { id: 2, name: '北海道' }, { id: 3, name: '青森県' }, { id: 4, name: '岩手県' }, { id: 5, name: '宮城県' }, { id: 6, name: '秋田県' }, { id: 7, name: '山形県' }, { id: 8, name: '福島県' }, { id: 9, name: '茨城県' }, { id: 10, name: '栃木県' }, { id: 11, name: '群馬県' }, { id: 12, name: '埼玉県' }, { id: 13, name: '千葉県' }, { id: 14, name: '東京都' }, { id: 15, name: '神奈川県' }, { id: 16, name: '新潟県' }, { id: 17, name: '富山県' }, { id: 18, name: '石川県' }, { id: 19, name: '福井県' }, { id: 20, name: '山梨県' }, { id: 21, name: '長野県' }, { id: 22, name: '岐阜県' }, { id: 23, name: '静岡県' }, { id: 24, name: '愛知県' }, { id: 25, name: '三重県' }, { id: 26, name: '滋賀県' }, { id: 27, name: '京都府' }, { id: 28, name: '大阪府' }, { id: 29, name: '兵庫県' }, { id: 30, name: '奈良県' }, { id: 31, name: '和歌山県' }, { id: 32, name: '鳥取県' }, { id: 33, name: '島根県' }, { id: 34, name: '岡山県' }, { id: 35, name: '広島県' }, { id: 36, name: '山口県' }, { id: 37, name: '徳島県' }, { id: 38, name: '香川県' }, { id: 39, name: '愛媛県' }, { id: 40, name: '高知県' }, { id: 41, name: '福岡県' }, { id: 42, name: '佐賀県' }, { id: 43, name: '長崎県' }, { id: 44, name: '熊本県' }, { id: 45, name: '大分県' }, { id: 46, name: '宮崎県' }, { id: 47, name: '鹿児島県' }, { id: 48, name: '沖縄県' } ] include ActiveHash::Associations has_many :posts end ActiveHash については割愛しますが、上のように都道府県ごとにid で投稿に紐付けられるようにしています。 ルーティング routes.rb Rails.application.routes.draw do devise_for :users root to: "posts#index" resources :posts do collection do get 'search' get 'search_kanto' end end end モデル post.rb class Post < ApplicationRecord belongs_to :user has_one_attached :image has_many :favorites, dependent: :destroy extend ActiveHash::Associations::ActiveRecordExtensions belongs_to :prefecture # 〜省略〜 def self.search_kanto Post.where(prefecture_id: 9..15) end end where句で指定の都道府県が登録されている投稿を取得しています。 コントローラー posts_controller.rb class PostsController < ApplicationController # 〜省略〜 def search_kanto @posts_kanto = Post.search_kanto.order('created_at DESC') end end @posts_kantoに取得した投稿を代入して、ビューファイルで表示できるようにしています。 学んだこと where句を使って検索条件を指定する部分が自分でやってみて多少は理解できたかなというところです。 最初にも書いたように地方ごとにほぼ同様のコードを書いているので、そこは何とかシンプルにできるようにしたいです。 拙い内容とは思いますが読んでいただいてありがとうございます。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Rails]sessionとcookies。得意になりたくない?

はじめに Railsチュートリアルでも出てきました「session」と「cookies」の使い方がかなり難しいなと感じたのですが、同じ気持ちになった人は、たくさんいるかと思います。。 初心者では、チュートリアル完遂後にすぐ理解して使いこなすのは難しいと思うので、この記事を見れば「使い方がわかった!」というレベルまで持っていけたらと思います! sessionについて sessionとは、ステートフルな通信を実現するための仕組みのことです。 sessionは主にログイン機能に使用されるもので、ログイン状態を持続させるためにあります。 また、sessionの情報はRails標準では、ブラウザ側のクッキーに保存されるということも合わせて覚えておいてください。 sessionは明示的に削除する(又は有効期限切れになる)まで消えないので、不要になった際には削除するようにしましょう。 sessionの使い方 セッションに情報を保存する # session[キー] = 値 session[:user_name] = 'test' #=> sessionに[:user_name]という名前をつけて「'test'」という文字を格納 セッション情報を削除 reset_session #=> 全てのsessionを削除 session[:user_name] = nil session[:user_name].clear session.delete(:user_name) #=> [:user_name]の値を削除(3つとも) 現在のセッション情報を取得 session.session_id #=> 現在保存されているsession情報を取得 実際に使ってみる! よくある使い道として、ログイン機能がありますが、簡単に実装手順を示しました! ※Userモデル作成済の前提で行っています。 1.Gemfileに以下を追記する。 gem 'bcrypt' # データベースにパスワードを保存する際に、暗号化してくれる 追記後は、以下コマンドを実行する。 $ bundle install 2.パスワードの暗号化を有効にするための設定をする。 app/models/user.rb ... has_secure_password ... 3.コントローラーにsessionに関する記述を記載する。 app/controllers/sessions_controller.rb class SessionsController < ApplicationController def index if session[:user_name] @notice = "#{session[:user_name]}でログイン済です。" # セッションに情報が保存されるか判断 end if params.key?(:name) || params.key?(:password) user = User.find_by_name(params[:name]) if user && user.authenticate(params[:password]) # パスワードがユーザーがポストした値と一致しているのか判断 session[:user_name] = params[:name] # sessionを設定 else session[:user_name] = nil # sessionを削除 end end end end cookiesについて cookiesとは、クライアント側のブラウザにあるデータ保存領域のこと。 Railsではデフォルトではsessionを管理するためにブラウザのcookieを利用する。 そもそもcookieは、Webアプリがブラウザを通してクライアントにデータを保持させる機能のことで、要するにユーザー側に持たせている情報と思えばいい。クライアントが情報を持っているので、ブラウザを閉じても破棄されないし、暗号化しないと簡単に読み取られて情報を奪われてしまいます。 実際にはDBにもハッシュ化した情報を保存し、次回訪問時にCookieの内容と突き合わせてログイン状態を参照するという方法を取ることが多いです。 cookiesの使い方 cookieの保存 # cookies[:cookie名] = { key: cookie情報 } cookies[:user_name] = "david" #=> cookieに保存 cookies[:lat_lon] = [47.68, -122.37] #=> cookieに配列を保存 cookies[:login] = { value: "XJ-122", expires: 1.hour.from_now } #=> cookieにハッシュを保存 オプション 説明 デフォルト値 :value cookieの値 - :path cookieが有効なパス - :domain cookieが有効なドメイン 現在のホスト :expires cookieの有効期限 / :secure 暗号化通信でのみcookieを送信 false :httponly HTTPcookieを有効 false 永続化cookie(有効期間が20年に設定されたクッキー)を設定 # cookies.permanent[cookie名] = 値 cookies.permanent[:user_name] = "Jamie" #=> [:user_name]に"Jamie"を永続化cookieとして保存 署名付きcookie(クライアント側の改ざん防止可能)を設定 # cookies.signed[クッキー名] = 値 cookies.signed[:user_id] = 45 #=> 署名付きcookieを設定 cookieの削除 # cookies.delete(:クッキー名 [, 対象のドメイン、またはパス]) cookies.delete :user_name #=> :user_nameで保存されているcookieを削除 終わりに 使い方自体は意外と簡単なものですね! これをどのように使うのが難しい所ですが、、、、 大体は、ログイン機能や検索条件の保存などが多いと思うので、その場面に直面した際はこの記事で培った知識を発揮してください!!! 参考 【Rails】Sessionの使い方について 【Rails入門】sessionの使い方まとめ Rails ドキュメント クッキー・キャッシュ
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

7桁の数字入力に制限するバリデーションnumericality

railsガイド https://railsguides.jp/active_record_validations.html 参考に行いました class Player < ApplicationRecord validates :points, numericality: true validates :games_played, numericality: { only_integer: true } end オプション 働き 表示メッセージ greater_than 指定された値よりも大きくなければならない 「must be greater than %{count}」 greater_than_or_equal_to 指定された値と等しいか、それよりも大きくなければならない 「must be greater than or equal to %{count}」 equal_to 指定された値と等しくなければならない 「must be equal to %{count}」 less_than 指定された値よりも小さくなければならない 「must be less than %{count}」 less_than_or_equal_to 指定された値と等しいか、それよりも小さくなければならない 「must be less than or equal to %{count}」 other_than 渡した値以外の値でなければならない 「must be other than %{count}」 odd trueの場合は奇数でなければない 「must be odd」 even trueの場合は偶数でなければない 「must be even」です。 自アプリに転用 models/item.rb validates :price, numericality:{only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 9999999 }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Rails6 Devise導入 Bootstrap対応/日本語対応/名前登録まで

自作のサービスを作る際、環境構築同様毎度同じことをやるのでまとめておく。 事前準備 rails sでページ表示済み Deviseインストールする。 Gemfileに追加する gem "devise" bundle install bin/rails g devise:install ↑このコマンドを実行すると4つのセットアップ方法が表示されるので従う。 config/environments/development.rb config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } 本番用もこの時点で記述しておく config/environments/production.rb # 本番環境ではhost名を指定 config.action_mailer.default_url_options = { host: 'xxxx.com' } ルーティングの設定を行う config/routes.rb root to: "home#index" 次にトップページ用のコントローラーを生成 今回はDeivseの指定に合わせてhome indexで用意。対応させていれば自由に変更OK bin/rails g controller home index 2行のflashメッセージを追加する。 自分はbootstrapのcontainerクラス内かつ、パーシャル化させる。 app/views/layouts/application.html.erb <p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p> Deviseのビューファイルをコピー bin/rails g devise:views Userモデルを作成 bin/rails g devise User usersテーブルを作成 bin/rails db:migrate ↑要らぬ情報だけど、この時なぜか(この記事内では関係なかったため割愛)usersテーブルが既にありますというエラーが出たためrails db:migrate:resetで対処した。 この時点で、ログインしないとアクセスできないようにする。 もしこれをしない場合は、rails sで確認する際に/users/sign_inで確認することを忘れないよう注意。 作りたいサービスによるが、ログインしていなくてもトップページ一覧は閲覧できるようにしたい場合は、application_controller.rbではなく、トップページ(今回はhome)のControllerに記載+indexのみ除外などの工夫をすべき? app/controllers/application_controller.rb class ApplicationController < ActionController::Base before_action :authenticate_user! end フォームが正しく表示されているか確認 bin/rails s ↑要らぬ情報2だけど、自分はDocker使用していて、一旦downして再度upしないとルーティングエラーになった。要は色々生成しているから更新しようということ。 これでお馴染みのフォームが生成されていればOK DeviseにBootstrapを適用 必要なものをインストール(バージョンは指定しなくても。) $ yarn add bootstrap@4.4.1 jquery@3.5.1 popper.js@1.16.1 config/webpack/environment.jsに追記 config/webpack/environment.js const { environment } = require('@rails/webpacker') const webpack = require('webpack') environment.plugins.append('Provide', new webpack.ProvidePlugin({ $: 'jquery', jQuery: 'jquery', Popper: ['popper.js', 'default'] })) module.exports = environment app/javascript/packs/application.jsに追記 app/javascript/packs/application.js require("bootstrap/dist/js/bootstrap") application.cssの拡張子cssをscssに変更 最後にapp/assets/stylesheets/application.scssに追記 app/assets/stylesheets/application.scss /* *= require_tree . *= require_self */ @import "bootstrap/scss/bootstrap"; Viewを編集 レスポンシブ対応のviewportを忘れずに記載しておく app/views/layouts/application.html.erb .. <%= csrf_meta_tags %> <%= csp_meta_tag %> <meta name="viewport" content="width=device-width,initial-scale=1"> .. <body> <header> <nav class="navbar navbar-expand navbar-light"> <%= link_to "サンプル", root_path, class: 'navbar-brand' %> <div id="Navber"> <ul class="navbar-nav"> <% if user_signed_in? %> <li class="nav-item active"> <%= link_to 'アカウント編集', edit_user_registration_path, class: 'nav-link' %> </li> <li class="nav-item active"> <%= link_to 'ログアウト', destroy_user_session_path, method: :delete, class: 'nav-link' %> </li> <% else %> <li class="nav-item active"> <%= link_to "新規登録", new_user_registration_path, class: 'nav-link' %> </li> <li class="nav-item active"> <%= link_to "ログイン", new_user_session_path, class: 'nav-link' %> </li> <% end %> </ul> </div> </nav> </header> <div class="container"> <p class="notice"><%= notice %></p> <p class="alert"><%= alert %></p> .. devise用のbootstrapのGemを追加する (後の日本語化のGemもこの時点で追加しておきます。) .. # 日本語化 gem 'rails-i18n', '~> 6.0' gem 'devise-i18n' # Bootstrap gem 'devise-bootstrap-views', '~> 1.0' 既にdeviseのviewsをinstallしている場合は、下記コマンドでbootstrap用のテンプレートに変更する (このコマンドを使用すると、devise関連のviewにbootstrap用のclassが追加される) (このコマンドをしないと、ページはbootstrap適用されるが、devise関連が適用されない) **このコマンドを打つと、既にあるviewファイルとコンフリクトを起こすがa (all, overwrite this and all others)で上書きすれば大丈夫 bin/rails g devise:views:bootstrap_templates http://localhost:3000 で確認してみましょう このようになっていれば無事適用 Deviseに名前も登録する まず、usersテーブルにnameを追加する。 emailに適用されているユニークインデックスをnameに適用 rails g migration add_name_to_users name:string:uniq db/migrate/~~~~_add_name_to_users.rb class AddNameToUsers < ActiveRecord::Migration[6.0] def change add_column :users, :name, :string add_index :users, :name, unique: true end end bin/rails db:migrate nameにバリデーション設定 app/models/user.rb .. validates :username, uniqueness: true, presence: true 設定ファイルで認証キーを変更 config/initializers/devise.rb - # config.authentication_keys = [:email] + config.authentication_keys = [:username] viewファイルを変更 まずは新規登録画面 nameを一番上にする場合は、デフォルトではemailに設定されているautofocusを変更する app/views/devise/registrations/new.html.erb .. <%= bootstrap_devise_error_messages! %> <!-- nameの入力欄を追加 --> <div class="form-group"> <%= f.label :name %> <%= f.text_field :name, autofocus: true, autocomplete: 'name', class: 'form-control' %> </div> <div class="form-group"> <%= f.label :email %> <%= f.email_field :email, autocomplete: 'email', class: 'form-control' %> </div> .. StrongParameterにname追加 app/controllers/application_controller.rb class ApplicationController < ActionController::Base before_action :configure_permitted_parameters, if: :devise_controller? protected def configure_permitted_parameters added_attrs = [ :name, :email, :password, :password_confirmation ] devise_parameter_sanitizer.permit :sign_up, keys: added_attrs devise_parameter_sanitizer.permit :account_update, keys: added_attrs devise_parameter_sanitizer.permit :sign_in, keys: added_attrs end end ログインも変更 app/views/devise/sessions/new.html.erb .. <%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %> <div class="form-group"> <%= f.label :name %> <%= f.text_field :name, autofocus: true, autocomplete: 'name', class: 'form-control' %> </div> <div class="form-group"> <%= f.label :email %> <%= f.email_field :email, autocomplete: 'email', class: 'form-control' %> </div> .. ここまでで、新規登録/ログインともに名前込みで出来るようになったと思います deviseを日本語に config/application.rb .. config.load_defaults 6.0 # 日本語 config.i18n.default_locale = :ja # タイムゾーン変更 config.time_zone = 'Asia/Tokyo' .. 一度サーバーを落ちしてから確認してみてください。 http://localhost:3000 日本語の変更 下記のコマンドで生成されるdevise.views.ja.yml内を編集することで変更出来る bin/rails g devise:i18n:locale ja アカウント登録⇨新規登録 パスワードを忘れましたか?⇨パスワードの再設定 の方が自然に感じると思います。 参考記事 Devise入門64のレシピ その1 [Rails] deviseの使い方(rails6版) Deviseでログイン機能を追加・日本語化・Bootstrap4適用まで deviseで作成したUserモデルにusernameカラムを追加してDBへ登録できるようにする 【Rails】この記事を「devise 名前 ログイン」で調べたあなたへ贈る
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

rails db:migrate をしたらMysql2::Error: Table 'テーブル名' doesn't exist と怒られる問題

何が起こったのか? migrationファイルを追加していつものように rails db:migrate を叩いたところ、以下のように怒られた。 StandardError: An error has occurred, all later migrations canceled: Mysql2::Error: Table 'Mysql内のテーブル名' doesn't exist <略> Caused by: ActiveRecord::StatementInvalid: Mysql2::Error: Table 'Mysql内のテーブル名' doesn't exist ActiveRecord::StatementInvalidとは? 簡潔に言えば「記述が違うぞ?」ということ。 ※Mysql内のテーブル名は % rails db を叩いてMysqlに入り、SHOW TABLES; で確認できます。 mysql> SHOW TABLES; 確認すべきところ ・ migrationファイルのテーブル名 → タイポしていないかどうか(ex:スペルミスや大文字になっていないかなど) ちなみに自分の場合はsorcery_coreのmigrationファイル内の「create_table :users」が大文字になっていたことが原因でした。 変更前 class SorceryCore < ActiveRecord::Migration[6.0] def change create_table :Users do |t| t.string :email, null: false t.string :crypted_password t.string :salt t.timestamps null: false end add_index :users, :email, unique: true end end 変更後 class SorceryCore < ActiveRecord::Migration[6.0] def change create_table :users do |t| t.string :email, null: false t.string :crypted_password t.string :salt t.timestamps null: false end add_index :users, :email, unique: true end end タイポに気をつけて 初歩的ではあるものの、意外と気付き難いが故に根深い問題の1つではないでしょうか? こまめに確認して、どこに問題があるのかを探しやすくすることが大切だと痛感しました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Elasticsearch-railsのサンプルを触ってみた

環境構築 Rails6/Docker/MySQL + Elasticsearch 環境構築 が完了した時点からのスタート Gem追加 gem 'elasticsearch-model', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' gem 'elasticsearch-rails', git: 'git://github.com/elasticsearch/elasticsearch-rails.git' bin/bundle install model作成 # コンテナ内に入る docker exec -it railsのコンテナ名 bash bin/rails g model author name:string bin/rails g model publisher name:string bin/rails g model category name:string bon/rails g model manga author:references publisher:references category:references title:string description:text bi/rails db:migrate seedにサンプルデータを投入 seeds.rb # This file should contain all the record creation needed to seed the database with its default values. # The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). # # Examples: # # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) # Character.create(name: 'Luke', movie: movies.first) # category ct1 = Category.create(name: 'バトル・アクション') ct2 = Category.create(name: 'ギャグ・コメディ') ct3 = Category.create(name: 'ファンタジー') ct4 = Category.create(name: 'スポーツ') ct5 = Category.create(name: 'ラブコメ') ct6 = Category.create(name: '恋愛') ct7 = Category.create(name: '異世界') ct8 = Category.create(name: '日常系') ct9 = Category.create(name: 'グルメ') ct10 = Category.create(name: 'ミステリー・サスペンス') ct11 = Category.create(name: 'ホラー') ct12 = Category.create(name: 'SF') ct13 = Category.create(name: 'ロボット') ct14 = Category.create(name: '歴史') ct15 = Category.create(name: '少女漫画') ct16 = Category.create(name: '戦争') ct17 = Category.create(name: '職業・ビジネス') ct18 = Category.create(name: 'お色気') ct19 = Category.create(name: '学園もの') # 出版社 pb1 = Publisher.create(name: '集英社') pb2 = Publisher.create(name: '講談社') pb3 = Publisher.create(name: '小学館') pb4 = Publisher.create(name: '芳文社') pb5 = Publisher.create(name: '双葉社') # 作者 at1 = Author.create(name: '原泰久') at2 = Author.create(name: '堀越耕平') at3 = Author.create(name: '清水茜') at4 = Author.create(name: '井上雄彦') at5 = Author.create(name: '吉田秋生') at6 = Author.create(name: '野田サトル') at7 = Author.create(name: 'あfろ') at8 = Author.create(name: '神尾葉子') at9 = Author.create(name: '冨樫義博') at10 = Author.create(name: '川上秦樹') at11 = Author.create(name: 'こうの史代') at12 = Author.create(name: '古舘春一') at13 = Author.create(name: '三田紀房') at14 = Author.create(name: '藤沢とおる') # 漫画 Manga.create(title: "キングダム", publisher: pb1, author: at1, category: ct14, description: "時は紀元前―。いまだ一度も統一されたことのない中国大陸は、500年の大戦争時代。苛烈な戦乱の世に生きる少年・信は、自らの腕で天下に名を成すことを目指す!!") Manga.create(title: "僕のヒーローアカデミア", publisher: pb1, author: at3, category: ct1, description: "多くの人間が“個性という力を持つ。だが、それは必ずしも正義の為の力ではない。しかし、避けられぬ悪が存在する様に、そこには必ず我らヒーローがいる! ん? 私が誰かって? HA‐HA‐HA‐HA‐HA! さぁ、始まるぞ少年! 君だけの夢に突き進め! “Plus Ultra!!") Manga.create(title: "はたらく細胞", publisher: pb2, author: at3, category: ct1, description: "人間1人あたりの細胞の数、およそ60兆個! そこには細胞の数だけ仕事(ドラマ)がある! ウイルスや細菌が体内に侵入した時、アレルギー反応が起こった時、ケガをした時などなど、白血球と赤血球を中心とした体内細胞の人知れぬ活躍を描いた「細胞擬人化漫画」の話題作、ついに登場!!肺炎球菌! スギ花粉症! インフルエンザ! すり傷! 次々とこの世界(体)を襲う脅威。その時、体の中ではどんな攻防が繰り広げられているのか!? 白血球、赤血球、血小板、B細胞、T細胞...etc.彼らは働く、24時間365日休みなく!") Manga.create(title: "スラムダンク SLAM DUNK 新装再編版", publisher: pb1, author: at4, category: ct4, description: '中学時代、50人の女の子にフラれた桜木花道。そんな男が、進学した湘北高校で赤木晴子に一目惚れ! 「バスケットは…お好きですか?」。この一言が、ワルで名高い花道の高校生活を変えることに!!') Manga.create(title: "BANANA FISH バナナフィッシュ 復刻版全巻BOX", publisher: pb3, author: at5, category: ct15, description: 'フラワーコミックスの黄色いカバーを完全再現!!吉田秋生の不朽の名作が復刻版BOXとなって登場しました。フラワーコミックスの黄色いカバーを完全再現したコミックスと、特典ポストカードをセットにした完全保存版。ポストカードはファン垂涎の、アッシュ・英二のイラストをセレクトしたここでしか手に入らないオリジナルです。') Manga.create(title: "ゴールデンカムイ", publisher: pb1, author: at6, category: ct1, description: '『不死身の杉元』日露戦争での鬼神の如き武功から、そう謳われた兵士は、ある目的の為に大金を欲し、かつてゴールドラッシュに沸いた北海道へ足を踏み入れる。そこにはアイヌが隠した莫大な埋蔵金への手掛かりが!? 立ち塞がる圧倒的な大自然と凶悪な死刑囚。そして、アイヌの少女、エゾ狼との出逢い。『黄金を巡る生存競争』開幕ッ!!!!') Manga.create(title: "ゆるキャン△", publisher: pb4, author: at7, category: ct8, description: '富士山が見える湖畔で、一人キャンプをする女の子、リン。一人自転車に乗り、富士山を見にきた女の子、なでしこ。二人でカップラーメンを食べて見た景色は…。読めばキャンプに行きたくなる。行かなくても行った気分になる。そんな新感覚キャンプマンガの登場です!') Manga.create(title: "花のち晴れ〜花男 Next Season〜", publisher: pb1, author: at8, category: ct6, description: '英徳学園からF4が卒業して2年…。F4のリーダー・道明寺司に憧れる神楽木晴は、「コレクト5」を結成し、学園の品格を保つため“庶民狩りを始めた!! 隠れ庶民として学園に通う江戸川音はバイト中に晴と遭遇し!?') Manga.create(title: "HUNTER×HUNTER ハンター×ハンター", publisher: pb1, author: at9, category: ct1, description: '父と同じハンターになるため、そして父に会うため、ゴンの旅が始まった。同じようにハンターになるため試験を受ける、レオリオ・クラピカ・キルアと共に、次々と難関を突破していくが…!?') Manga.create(title: "転生したらスライムだった件", publisher: pb2, author: at10, category: ct7, description: '通り魔に刺されて死んだと思ったら、異世界でスライムに転生しちゃってた!?相手の能力を奪う「捕食者」と世界の理を知る「大賢者」、2つのユニークスキルを武器に、スライムの大冒険が今始まる!異世界転生モノの名作を、原作者完全監修でコミカライズ!') Manga.create(title: "この世界の片隅に", publisher: pb5, author: at11, category: ct16, description: '平成の名作・ロングセラー「夕凪の街 桜の国」の第2弾ともいうべき本作。戦中の広島県の軍都、呉を舞台にした家族ドラマ。主人公、すずは広島市から呉へ嫁ぎ、新しい家族、新しい街、新しい世界に戸惑う。しかし、一日一日を確かに健気に生きていく…。') Manga.create(title: "スラムダンク SLAM DUNK", publisher: pb1, author: at4, category: ct4, description: '中学3年間で50人もの女性にフラれた高校1年の不良少年・桜木花道は背の高さと身体能力からバスケットボール部の主将の妹、赤木晴子にバスケット部への入部を薦められる。彼女に一目惚れした「初心者」花道は彼女目当てに入部するも、練習・試合を通じて徐々にバスケットの面白さに目覚めていき、才能を開花させながら、全国制覇を目指していくのであったが……。') Manga.create(title: "ハイキュー!!", publisher: pb1, author: at12, category: ct4, description: 'おれは飛べる!! バレーボールに魅せられ、中学最初で最後の公式戦に臨んだ日向翔陽。だが、「コート上の王様」と異名を取る天才選手・影山に惨敗してしまう。リベンジを誓い烏野高校バレー部の門を叩く日向だが!?') Manga.create(title: "インベスターZ", publisher: pb2, author: at13, category: ct17, description: '創立130年の超進学校・道塾学園に、トップで合格した財前孝史。入学式翌日に、財前に明かされた学園の秘密。各学年成績トップ6人のみが参加する「投資部」が存在するのだ。彼らの使命は3000億を運用し、年8%以上の利回りを生み出すこと。それゆえ日本最高基準の教育設備を誇る道塾学園は学費が無料だった!「この世で一番エキサイティングなゲーム、人間の血が最も沸き返る究極の勝負……それは金……投資だよ!」') Manga.create(title: "GTO", publisher: pb2, author: at14, category: ct19, description: "かつて最強の不良「鬼爆」の一人として湘南に君臨した鬼塚英吉は、辻堂高校を中退後、優羅志亜(ユーラシア)大学に替え玉試験で入学した。彼は持ち前の体力と度胸、純粋な一途さと若干の不純な動機で、教師を目指した。無茶苦茶だが、目先の理屈よりも「ものの道理」を通そうとする鬼塚の行為に東京吉祥学苑理事長の桜井良子が目を付け、ある事情を隠して中等部の教員として採用する。学園内に蔓延する不正義や生徒内に淀むイジメの問題、そして何より体面や体裁に振り回され、臭いものに蓋をして見て見ぬ振りをしてしまう大人たち、それを信じられなくなって屈折してしまった子どもたち。この学園には様々な問題が山積していたのである。桜井は、鬼塚が問題に真っ向からぶつかり、豪快な力技で解決してくれることに一縷の望みを託すようになる。") bin/rails db:seed Controller, View, Routing を追加 bin/rails g controller Mangas index app/controllers/mangas_controller.rb class MangasController < ApplicationController def index @mangas = Manga.all end end config/route.rb Rails.application.routes.draw do root 'mangas#index' resources :mangas, only: %i(index) end app/views/mangas/index.html.erb <h1>Mangas</h1> <table> <thead> <tr> <th>Aauthor</th> <th>Publisher</th> <th>Category</th> <th>Author</th> <th>Title</th> <th>Description</th> <th colspan="3"></th> </tr> </thead> <tbody> <% @mangas.each do |manga| %> <tr> <td><%= manga.author.name %></td> <td><%= manga.publisher.name %></td> <td><%= manga.category.name %></td> <td><%= manga.author.name %></td> <td><%= manga.title %></td> <td><%= manga.description %></td> </tr> <% end %> </tbody> </table> この時点でページを確認しようと、dockerを再度upしたところ、Gemfileが変更されていることによって?、railsのコンテナが起動しなかったため、buildしなおした。 docker-compose build もしくは、bundle install するなら、 docker-compose run web bundle install ここで一旦ページにseedで登録した内容が表示されているか確認しましょう ここからelasticsearchの部分に入っていきます configの設定 config/initializers/elasticsearch.rb # 「elasticsearch」はdocker-composeのservicesに設定した名前に合わせる config = { host: ENV['ELASTICSEARCH_HOST'] || "elasticsearch:9200/", } Elasticsearch::Model.client = Elasticsearch::Client.new(config) concernsの設定 app/models/manga.rb class Manga < ApplicationRecord include MangaSearchable belongs_to :author belongs_to :publisher belongs_to :category end app/models/concerns/manga_searchable.rb module MangaSearchable extend ActiveSupport::Concern included do include Elasticsearch::Model # Callbacksを指定すると、テーブルの更新時にelasticsearchも更新される include Elasticsearch::Model::Callbacks # ①index名 index_name "es_manga_#{Rails.env}" # ②マッピング情報 settings do mappings dynamic: 'false' do indexes :id, type: 'integer' indexes :publisher, type: 'keyword' indexes :author, type: 'keyword' indexes :category, type: 'text', analyzer: 'kuromoji' indexes :title, type: 'text', analyzer: 'kuromoji' indexes :description, type: 'text', analyzer: 'kuromoji' end end # ③mappingの定義に合わせてindexするドキュメントの情報を生成する def as_indexed_json(*) attributes .symbolize_keys .slice(:id, :title, :description) .merge(publisher: publisher_name, author: author_name, category: category_name) end end def publisher_name publisher.name end def author_name author.name end def category_name category.name end class_methods do # ④indexを作成するメソッド def create_index! client = __elasticsearch__.client # すでにindexを作成済みの場合は削除する client.indices.delete index: self.index_name rescue nil # indexを作成する client.indices.create(index: self.index_name, body: { settings: self.settings.to_hash, mappings: self.mappings.to_hash }) end end end このファイル内の動きはこの後説明(自身もまず動くものを確認したいので後ほど) 動作確認 bin/rails c elasticsearchの接続確認 pry(main)> Manga.__elasticsearch__.client.cluster.health => {"cluster_name"=>"docker-cluster", "status"=>"green", "timed_out"=>false, "number_of_nodes"=>1, "number_of_data_nodes"=>1, "active_primary_shards"=>0, "active_shards"=>0, "relocating_shards"=>0, "initializing_shards"=>0, "unassigned_shards"=>0, "delayed_unassigned_shards"=>0, "number_of_pending_tasks"=>0, "number_of_in_flight_fetch"=>0, "task_max_waiting_in_queue_millis"=>0, "active_shards_percent_as_number"=>100.0} indexの作成 pry(main)> Manga.create_index! => {"acknowledged"=>true, "shards_acknowledged"=>true, "index"=>"es_manga_development"} データの登録 Manga.__elasticsearch__.import (1.8ms) SET NAMES utf8mb4, @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'), @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483 Manga Load (1.0ms) SELECT `mangas`.* FROM `mangas` ORDER BY `mangas`.`id` ASC LIMIT 1000 Publisher Load (1.4ms) SELECT `publishers`.* FROM `publishers` WHERE `publishers`.`id` = 1 LIMIT 1 . . 検索機能の追加 ここで自分は詰まったのだが、integer型のidを含めると、数字は検索可能なのだが、テキストはエラーになる。(型の違いによるエラー) multi_match type: 'cross_fields'を指定することで複数typeも検索可能だと思っていたのだが、上手く動作しなかったため、今回idは削除。 config/initializers/elasticsearch.rb class_methods do # ... # indexes :id, type: 'integer' # ... def es_search(query) __elasticsearch__.search({ query: { multi_match: { fields: %w(publisher author category title description), type: 'cross_fields', query: query, operator: 'and' # 検索fieldsによって重み付けしたい時(例:titleでヒットした場合は2倍) # fields: ["title^2", "descritption"] } } }) end end end Controller修正 app/controllers/mangas_controller.rb class MangasController < ApplicationController def index @mangas = if search_word.present? Manga.es_search(search_word).records else Manga.all end end private def search_word @search_word ||= params[:search_word] end end Viewの修正 app/views/mangas/index.html.erb // ... // 検索フォームを追加 <div> <%= form_tag(mangas_path, method: :get) do %> <div> <%= text_field_tag :search_word, @search_word, placeholder: "漫画を検索する" %> </div> <div> <%= submit_tag "検索" %> </div> <% end %> </div> <div> <table> // ... 動作確認 ここまでで一旦動作します。 自分の場合は、なかなか動かず時間がかかりました。 参考記事 RailsとElasticsearchで検索機能をつくり色々試してみる - その1:サンプルアプリケーションの作成 Rails6でElasticsearchのキーワード検索実装ハンズオン elastisearch-railsを使ってRailsでElasticsearchを動かす【初心者向け】 Elastic SearchでString型のカラムが取り込めないエラー Query DSL
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Rails】puma.rbの気になるあれこれ

背景 Railsを起動する時に出てくる「puma」。気になったところを設定ファイルの「puma.rb」を中心に調べてみました。 ❯ rails s #railsを起動した時に目にする画面。Booting Pumaと書いてある。 => Booting Puma => Rails 6.0.3.5 application starting in development => Run `rails server --help` for more startup options Puma starting in single mode... * Version 3.12.6 (ruby 2.6.5-p114), codename: Llamas in Pajamas * Min threads: 5, max threads: 5 * Environment: development * Listening on tcp://localhost:3000 Use Ctrl-C to stop ^C- Gracefully stopping, waiting for requests to finish === puma shutdown: 2021-04-17 07:49:47 +0900 === - Goodbye! ※内容に間違いなどがある場合はご指摘をよろしくお願いします。 そもそもPumaって何? Railsを動かすアプリケーションサーバーの一つ。他にもMongrel、Unicorn、Thin、Rainbowsなど様々な種類がある。どれもアプリケーションを動かすために必要であり、書いたコードを読み込んで処理を行います。あくまでもアプリケーション内での動きに関わるらしく、Web上でアプリを起動させるためには、webサーバーが必要になります。 puma.rbの中身 max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } threads min_threads_count, max_threads_count port ENV.fetch("PORT") { 3000 } environment ENV.fetch("RAILS_ENV") { "development" } pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } plugin :tmp_restart threads(スレッド)? プログラムの処理の実行単位。これの数が増えると同時に実行できる処理の数が増えるみたいです。 max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } threads min_threads_count, max_threads_count 「max_threads_count」と「min_threads_count」に環境変数の値を代入し、「threads min_threads_count, max_threads_count」と書かれている記述によってpuma実行時に最大のスレッド数と最小のスレッド数を指定することができる。 port? pumaを起動した時にlocal環境のport番号を指定する。最初に「3000」と書いてありますが、この番号を変更するとport番号をrailsを実行する時にport番号を変えられます。 #port番号を3000から3001に変更 port ENV.fetch("PORT") { 3001 } port番号3001にrailsが起動しています。 environment? Pumaが起動する実行環境を指定します。 environment ENV.fetch("RAILS_ENV") { "development" } 「ENV.fetch("RAILS_ENV"){"development"}」の記述で「RAILS_ENV」と言う名前の環境変数を読み込むかそれがない場合には「development」という値が指定されます。 「development」は開発環境を意味し、他に「test」、「production」などがあります。それぞれテスト環境と本番環境を意味します。 pidfile? pid(Process ID)のことで、ここではpumaが起動した際の処理番号です。 pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 環境変数の「PIDFILE」の値を探し、なければ"tmp/pids/server.pid"の値が指定されます。場所は「railsアプリ名/tmp/pids/」です。server.pidをvscodeなどのエディターで開いてみる以下のように番号が書いてありました。 3157 コマンドラインで「ps -ef | grep puma」と入力し、pidを確認してみました。 ❯ ps -ef | grep puma 501 3157 2422 0 8:37AM ttys000 1:04.38 puma 3.12.6 (tcp://localhost:3001) [アプリ名] 501 3700 3321 0 8:54AM ttys002 0:00.01 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox puma 先ほどのserver.pidファイルに書かれていた「3157」のところをみてみると、「8時37分にlocalhost:3001でアプリケーションが実行されている記録が確認できます。 railsをstopし、再度確認してみると、 ❯ ps -ef | grep puma 501 3792 3321 0 8:55AM ttys002 0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox puma pid「3157」という番号はありませんでした。どうやらプロセス(pumaを起動)がstopするとpidも削除されるみたいです。念のため、「railsアプリ名/tmp/pids/」のフォルダーをみてみたら、server.pidファイルが削除されていることが分かりました。 plugin pumaに機能を追加する時に使うらしいです。最初から「tmp_restart」というプラグインが記述してあります。「tmp/restart.txtをtouchするとリスタートする」とか「rails restart」コマンドでpumaを再起動できるようにするプラグイン。実際にどういう使い方をするのかについてはあまり知られていません。(というか見つかりませんでした) 他には「puma-heroku」というheroku用の設定を用意してくれるプラグインもあります。 # Gemfile gem 'puma-heroku' # gemfileをインストール bundle install # config/puma.rb plugin :heroku 参考サイト・記事 https://qiita.com/jnchito/items/3884f9a2ccc057f8f3a3 https://qiita.com/Orangina1050/items/9d816017217614bc3ce7 https://wa3.i-3-i.info/word12453.html https://normalse.hatenablog.jp/entry/2019/04/09/083022 https://site-builder.wiki/posts/13878 https://github.com/puma/puma/blob/v3.12.0/lib/puma/plugin/tmp_restart.rb http://nekorails.hatenablog.com/entry/2018/10/12/101011
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

mysqlで外部キー制約を持っているテーブルのデータを削除する時は...

結論:SET FOREIGN_KEY_CHECKS = 0を使って制約を一時的に解除する こんにちは!スージーです mysqlで外部キー制約を持つテーブルのデータを削除する必要がある時に毎回調べている気がしたので備忘録として書き留めておきます 参考 railsガイド MySQL 5.6 リファレンスマニュアル モデルの関連付け railsを使っているのでrailsで説明します # item # 商品を管理するモデル create_table "items", charset: "utf8mb4", force: :cascade do |t| t.string "item_name" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false end # item_detail # 商品の詳細を管理するモデル create_table "item_details", charset: "utf8mb4", force: :cascade do |t| t.bigint "item_id" <= これが外部キー t.integer "item_price" t.string "description" t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false end # item_detail class CreateItemDetails < ActiveRecord::Migration[6.1] def change create_table :item_details do |t| t.references :item, foreign_key: { to_table: :items } <= これが外部キー制約 t.integer "item_price" t.string "description" t.timestamps end end end itemテーブルのデータを削除する 開発中に色々データを加工したり、削除したりする事があると思いますが、一回テーブルのデータをtruncateしようかなーと思ったら怒られた mysql > truncate table items; ERROR 1701 (42000): Cannot truncate a table referenced in a foreign key constraint (`test_item_development`.`item_details`, CONSTRAINT `fk_rails_30c7a965d1` FOREIGN KEY (`item_id`) REFERENCES `test_item_development`.`items` (`id`)) こんな時、開発環境であれば何も考えず以下を実行してtruncateさせちゃいます mysql > set foreign_key_checks = 0; mysql > truncate table items; => query ok・・・・ mysql > set foreign_key_checks = 1; set foreign_key_checks = 1;で元に戻しましょう おわり
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Js・vue.js】chart.jsでのデータ加工のやり方

こんにちは! 個人的に開発しているvuejs × rails apiのアプリにてグラフを使ったデータ表示を実装した時に躓いたポイントがあったので記事にしたいと思います。 環境 rails 5.2.3 rails 5.2.3 vue.js 2.6.12 参考 【Javascript】配列(オブジェクト)の操作【map/filter/some/reduce】 ActiveRecordで日付ごとにgroup byしたい Railsガイド 躓いたポイント chart.jsの使い方は参考記事がたくさんあったのですが、「じゃあapiで取得したデータをどうやってグラフに渡すの?」ってなり、なかなか参考記事が見つからずに苦労したので... 完成 これがchart.jsを使って表示している棒グラフです やりたい事 1週間の学習時間をDBから取得 1日に複数の学習時間を登録している場合は合計して1つの連想配列にまとめる 学習時間を登録していない日は学習時間を0とする 最終的に取得したデータをchart.jsに渡して棒グラフを表示する はじめにデータを取得 欲しいデータは以下のようなデータです [ {"id":9,"time":1.5,"user_id":1,"created_at":"2021-01-25T12:00:00.000+09:00","day_of_week":1}, {"id":11,"time":2.0,"user_id":1,"created_at":"2021-01-27T09:39:30.000+09:00","day_of_week":3}, {"id":14,"time":0.5,"user_id":1,"created_at":"2021-01-28T07:52:24.000+09:00","day_of_week":4} ] railsのactiveRecordを使って取得します まず今週の日〜土で期間指定します # models/study.rb def self.get_week_chart_data @this_day = Time.now @range = @this_day.all_week(:sunday) self.where(created_at: @range) end これでcreated_atが今週2021/01/24~2021/01/30で期間が指定できます 次にフロントで使うデータを指定して取得します def self.get_week_chart_data @this_day = Time.now @range = @this_day.all_week(:sunday) self.where(created_at: @range) .select(:id, "sum(time) as time", :user_id, :created_at, "dayofweek(created_at) - 1 as day_of_week") end 不要なデータも取ってきてますが気にしない笑 ポイントは、 - sum(time) as timeとして1日に複数回の学習時間(time)を登録している場合もあるので、その合計値を取得する - dayofweek(created_at) -1 as day_of_weekを使って曜日(この場合は曜日の添字である0~6)を取得する mysqlだとdayofweekで取得できる添字が1~7になるので注意 JsではgetDay()を使うと0~6が取得できる なんか揃ってないのが気になるので-1して揃えているだけ 最後に日付ごとにまとめる def self.get_week_chart_data @this_day = Time.now @range = @this_day.all_week(:sunday) self.where(created_at: @range) .select(:id, "sum(time) as time", :user_id, :created_at, "dayofweek(created_at) - 1 as day_of_week") .group("date(created_at)") end これで曜日ごとにグループ化できたので当初取得したかったデータが取得できる このモデルメソッドをコントローラに渡す. ついでにルーティングも設定 # controllers/studies_controller.rb def history histories = current_user.studies.get_week_chart_data // current_userの説明ははしょります. すいません! render json: histories end # config/routes.rb namespace :api, {format: 'json'} do namespace :v1 do get 'histories', controller: :studies, action: :history // RESTfulじゃないのは許して end end 本題 これで下準備が完了. chart.jsにデータを渡す // components/Chart.vue // これは公式の書き方のまま <script> import { Bar, mixins } from 'vue-chartjs'; const { reactiveProp } = mixins export default { extends: Bar, mixins: [reactiveProp], // reactiveProp使わないとデータ更新できないので注意 props: ['options'], mounted () { this.renderChart(this.chartData, this.options) } } </script> // History.vue <template> <div> <Chart class="chart" v-if="loaded" :chartData="chartData" :options="options"/> </div> </template> <script> import Chart from '../components/Chart'; export default { components: { Chart }, data() { histories: [], loaded: false, chartData: { labels: [], datasets: [] }, // これがchart.jsのデータの定義 options: { // ここの書き方はググるとたくさん出てくるので省略 } }, mounted () { this.$http.secured.get('/api/v1/histories') .then(response => { this.histories = response.data this.fillData() // エラー処理は省略 }, methods: { // ここでchartにデータを渡す処理書きます fillData () { this.loaded = false var data = this.arrayData() // ここでデータを加工するメソッドを呼び出しています this.chartData = { labels: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], datasets: [{ data: data }] // 配列の形でデータを渡す必要があります } this.loaded = true } } } </script> これで先程apiで取得したデータをchartに渡せるようになります ただ、まだjsonデータを加工してないのでarrayData()メソッドを書いていきます datasets:[{ data: }]に渡す時は配列にしてデータを渡します なので、jsonでは連想配列を配列にまとめた形でデータを受け取っているのでmapを使って配列に加工します ~省略~ methods: { arrayData () { var getData = this.histories; const times = getData.map(item => item.time) return times } ~省略~ } mapは連想配列のキーを指定する事でvalueだけ取り出してループ処理し配列を作ってくれます const times = getData.map(item => item.time) console.log(times) // => [1.5, 2.0, 0.5] あれ? 本来のデータ通りだと 1/25(月) => 1.5 1/27(水)=> 2.0 1/28(木)=> 0.5 にならなければいけないのに、mapで作ったデータでは 1/24(日) => 1.5 1/25(月) => 2.0 1/26(火) => 0.5 になっちゃってます 確かに配列データを作れたのでdatasets:[{ data: }]に渡せるデータにできましたが、1週間は7日なので、これだと日・月・火のデータになってしまっています... 現状の状態をまとめると、 [0, 1.5, 2.0, 0.5, 0, 0, 0]という配列を作らなくてはいけないのに、[1.5, 2.0, 0.5]という配列ができている状態です mapで作った配列はindex[0]から値を埋めていくので、3つの連想配列から取り出したプロパティはindex[0]~[2]に詰めてセットされてしまっています この問題を解消する為にやりたい事の3つ目学習時間を登録していない日は学習時間を0とする処理をarrayData()メソッドに書きます 考え方 (A) jsonデータのday_of_weekを使って比較用の配列を作成する (B) 1週間は7日なので比較対象の配列を用意する (A)と(B)を比較して(A)が持っていない数字(曜日)を求める 持っていない数字(曜日)番目に{ time: 0 }を突っ込んでやる(無理やり感...) 果たしてこれが良いやり方なのかは甚だ疑問だが、やってみよう データの成形 ~省略~ methods: { arrayData () { var getData = this.histories; // getDataの連想配列数が7つ未満の場合 if(getData.length < 7) { // (A)比較用の配列を作成する // 各連想配列created_atの曜日(添字)を抽出して配列を作成 const arrayDayOfWeek = getData.map(item => item.day_of_week) // => [1, 3, 4] // (B)比較対象の配列を用意する // 1週間7日分の曜日(添字)の数値を格納した配列を作成 const arraySeven = [0, 1, 2, 3, 4, 5, 6] // (A)と(B)を比較して差分を求める // filterメソッドを使って、arrayDay0fWeekが持っていない値を抽出 var resultArray = arraySeven.filter(i => arrayDayOfWeek.indexOf(i) == -1) // => [0, 2, 5, 6]がresultArrayに格納 // このケースだとresultArrayには4つデータが格納されているので4回ループされる // spliceメソッドを使ってgetDataの配列に格納されている[0, 2, 5, 6]番目に{time: 0}を追加 for(var i = 0; i < resultArray.length; i++) { getData.splice(resultArray[i], 0, {time: 0}) }; ↓ 結果 ↓ // 配列index番号の0, 2, 5, 6番目に{time: 0}が追加された(プロパティの数が違うのは気にしない) // getData = [ {time: 0} {id: 9, time: 1.5, user_id: 1, created_at: "2021-01-25T12:00:00.000+09:00", day_of_week: 1} {time: 0} {id: 11, time: 2, user_id: 1, created_at: "2021-01-27T09:39:30.000+09:00", day_of_week: 3} {id: 14, time: 0.5, user_id: 1, created_at: "2021-01-28T07:52:24.000+09:00", day_of_week: 4} {time: 0} {time: 0} ] // } const times = getData.map(item => item.time) return times // chartに渡したい配列を作成できた // => [0, 1.5, 0, 0.5, 0, 0, 0] } ~省略~ } for(var i = 0; i < resultArray.length; i++) { getData.splice(resultArray[i], 0, {time: 0}) }; この処理では、 resultArray[0] // => 配列0番目に格納されている値は[0] getData0番目にspliceメソッドによって{time: 0}が追加 resultArray[1] // => 配列1番目に格納されている値は[2] getData2番目にspliceメソッドによって{time: 0}が追加 をループ処理でresultArray.length分、繰り返します これで目的のデータの成形が完了したのでchartに渡せます chart.jsに成形したデータを渡す // 必要箇所だけ <template> <div> <Chart class="chart" v-if="loaded" :chartData="chartData" :options="options"/> // => chartDataにfillDataメソッドで生成されたデータが渡される // => optionsにdata() {}内で定義したoptionsデータを渡せる(この記事では省略してます) </div> </template> <script> export default { data() { histories: [], loaded: false, chartData: { labels: [], datasets: [] }, }, mounted() { this.$http.secured.get('/api/v1/histories') .then(response => { this.histories = response.data //jsonデータを受け取る this.fillData() // fillDataメソッドを呼び出す }) }, methods: { arrayData () { ~省略~ return times // 返り値 => [0, 1.5, 0, 0.5, 0, 0, 0] }, fillData () { this.loaded = false var data = this.arrayData() // [0, 1.5, 0, 0.5, 0, 0, 0]をdataに格納 this.chartData = { labels: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], datasets: [ { data: data } ] } // data() {}内で定義したchartData { labels: [], datasets: [] }に生成したデータが格納され, templateタグ内の<Chart ~~/>へマウントされる this.loaded = true } }, } </script> これで記事の最初に貼ったキャプチャの棒グラフが完成しました 最後に もっと効率的な書き方あれば教えて下さい! ちなみに、dayofweek()はmysqlで用意されているメソッドなのでpostgresqlだとエラーします... herokuでデプロイしたらエラーで萎えました... おわり
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Railsアプリに非同期通信のコメント機能を実装

はじめに Railsアプリに非同期通信のコメント機能を実装しました。 非同期通信は一言で言えば、画面遷移をせずにページを更新する技術です。 備忘録として手順をまとめたいと思います。 実装イメージ 開発環境 macOS Catalina Ruby 2.6.5 Ruby on Rails 5.2 目次 1.モデルとアソシエーション 2.ルーティング 3.コントローラー 4.view 1. モデルとアソシエーション モデル構成とアソシエーションはよくある構成です。 前提としてuserとpostモデルは作成済みとします。 commentモデル作成 $ rails g model comment user:references post:references content:string $ rails g migrate アソシエーション user.rb has_many :posts, dependent: :destroy has_many :comments, dependent: :destroy post.rb belongs_to :user has_many :comments, dependent: :destroy comment.rb belongs_to :user belongs_to :post # バリデーション(カラの入力を無効に)  validates :content, presence: true 2. ルーティング コメントは投稿に紐付いているのでルーティングはネストさせて記述します。 ネストさせることでアソシエーション先のレコード(今回で言えば、投稿に紐づくコメント)のidをparamsに追加してコントローラーにわたすことができるようになります。 routes.rb resources :posts, only: [:index, :new, :create, :show, :destroy] do resources :comments, only: [:create, :destroy] end 3. コントローラー まずはcommentのコントローラーを作成します。 $ rails g controller comments 次にcreateとdestroyアクションを定義します。 comments_controller.rb comments_controller.rb class CommentsController < ApplicationController def create @comment = Comment.create(comment_params) respond_to do |format| if @comment.save format.html { redirect_back(fallback_location: root_path) } # 前のページに遷移 format.js # create.js.erbが呼び出される else format.html { redirect_back(fallback_location: root_path) } # 前のページに遷移 end end end def destroy @post = Post.find(params[:post_id]) @comment = current_user.comments.find_by(post_id: @post.id) @comment.destroy redirect_back(fallback_location: root_path) end private def comment_params params.require(:comment).permit(:content).merge(user_id: current_user.id, post_id: params[:post_id]) end end Comment.create(comment_params) ・comment_paramsではmergeメソッドでuser_idとpost_idをcommentテーブルのレコードに格納します。 respond_to do |format| ・処理の結果をHTML形式で返すかJS形式で返すかを分岐させます。 ・今回はコメントが保存されたらJS形式で返すように設定します。format.jsと記述することでcreate.js.erbというファイルを返します。 redirect_back(fallback_location: root_path) ・もしJS形式で返せなかった場合は同期通信でコメントを作成します。その際redirect_backでコメント作成ページにとどまることができます。fallback_location: root_pathはエラーが起きた際にroot_pathに遷移するという記述です。 posts_controller.rb viewで反映させるためにposts_controller.rbを編集します。 posts_controller.rb def show @post = Post.find(params[:id]) @comment = Comment.new @comments = @post.comments.all end 4. View 非同期通信をさせるためにcreate.js.erbと_comment.html.erbという部分テンプレートを作成します。 一例ですがディレクトリ構成は以下のようになります。 views |-posts | |-show.html.erb |-comments |-_comment.html.erb |-create.js.erb 投稿詳細ページは以下のようになります。 posts/show.html.erb <% if @comments %> <div class="commentOutline"> <%= render partial: "modules/comment", locals: { comments: @comments }%> </div> <% else %> <p>コメントはまだありません</p> <% end %> <div class="bottomInput"> <%= form_with(model: [@post, @comment], id: "new-comment") do |f| %> <%= f.text_field :content, class: "inputComment" %> <%= f.submit "コメントする", class: "submitComment" %> <% end %> </div> renderメソッドで_comment.html.erbを呼び出します。更にlocalsオプションでposts_controller.rbで定義した@commnetsの変数をcommentsとして部分テンプレート内で使用できるようにします。 form_withはデフォルトでremote: tureになっているのでこれでJS形式でレスポンスすることができます。 部分テンプレートは以下の通りです。 comments/_comment.html.erb <% comments.each do |comment| %> <li> <div class="topPosition"> <%= link_to user_path(comment.user.id), class: "commentUserLink" do %> <% if comment.user.icon? %> <%= image_tag comment.user.icon.url, class: "commentUserIcon"%> <% else %> <i class="fas fa-user-circle"></i> <% end %> <p class="commentUserName"><%= comment.user.nickname %></p> <% end%> <% if comment.user_id == current_user.id %> <%= link_to post_comment_path(comment.post_id, comment.id), method: :delete, class: "deleteCommentLink" do %> <i class="fas fa-trash"></i> <% end %> <% end %> </div> <div class="bottomPosition"> <p class="commentContent"><%= comment.content %></p> <p class="commentDatetime"><%= comment.created_at.strftime('%Y/%m/%d') %></p> </div> </li> <% end %> 最後にcreate.js.erbを編集します。 comments/create.js.erb $(".commentsArea").html("<%= j(render 'comments/comment', { comments: @comment.post.comments }) %>") $(".inputComment").val(''); 1行目でコメントが作成されたらcommentsAreaに部分テンプレートの内容を更新するという記述をしています。 2行目はコメントを入力するinputエリアの値をリセットする記述です。なお記述を簡略にするためにjQueryで記述しています。 以上で非同期でのコメント機能が実装できていると思います!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む