Top View


Author kakudaisuke

駆け出しエンジニアスキルレベルアップ必然!レコードの外部キーの変更履歴を保存するには?(Ruby on Rails)

2021/09/28

どんな課題?

(課題はデフォルメしてあります)

あるお店では、家電製品を販売していて、客とスタッフがいます。客が例えば、Aを購入すると、そのAの購入に対して専任のアフターサービススタッフを付けます。しかし、そのアフターサービススタッフは変更される場合があるので、履歴を残しておきたいです。

データベースとモデルはこんな感じ↓

purchase: 購入
  - belongs_to :client
  - belongs_to :staff
client: 客
 - has_many :purchases
staff: スタッフ
 - has_many :purchases
purchase_main_staff_history: 担当スタッフの履歴(←これから作っていきます)

この課題を通して学べること

MVCのモデルに対する理解が深まり、少しだけ自由になれました!

バージョン

  • ruby 2.7.2
  • Rails 6.1.3 

実装!

Migration

purchase_main_staff_historiesテーブルを作ります。

class CreatePurchaseMainStaffHistories < ActiveRecord::Migration[6.1]
  def change
    create_table :purchase_main_staff_histories do |t|
      t.references :purchase,       null: false, foreign_key: true
      t.datetime   :changed_at,     null: false
      t.references :old_main_staff, null: false, foreign_key: { to_table: :staffs }
      t.references :new_main_staff,              foreign_key: { to_table: :staffs }
      t.references :changed_by,     null: false, foreign_key: { to_table: :staffs }

      t.timestamps
    end
  end
end
  • 外部キーのカラム名は自由につけられる!!:foreign_keyはそのリレーション先のテーブルのモデル名にするのが一般的ですが、それが絶対的なルールではないです(知らなかった!)。いろんな名前で外部キーのカラム名を設定できます。今回の例では、staffsテーブルへのリレーションをstaffではなく、old_main_staff, new_main_staff, はたまたchanged_byとわかりやすい名前にすることに成功しています。ただこの場合、マイグレーションファイルでは、foreign_key: trueではなくforeign_key: { to_table: :staffs }としてテーブル名を明示してあげる必要があります。
  • 外部キーのカラム名は自動的に_idが付与される:マイグレーションでt.references :old_main_staff_idとする必要はありません。ま、これは通常のモデル名で外部キー張る時の挙動を考えれば当然といえば当然ですが。。 
  • テーブルAとBの間で外部キーは複数張れちゃう:システムの規模が大きくなればなるほど、こういうケースが出てくるのかもしれませんね。
  • ちなみに、担当を削除することもあるので、new_main_staffにはnull制約をつけておりません。

あと蛇足ではありますが、以下が僕が最初に提出したMigrationコードです。上のポイントがわかってなかったという思い出に貼っておきますw。

class CreatePurchaseMainStaffHistories < ActiveRecord::Migration[6.1]
  def change
    create_table :purchase_main_staff_histories do |t|
      t.integer    :staff_before_id, null: false
      t.integer    :staff_after_id,  null: false
      t.datetime   :changed_at,      null: false
      t.references :purchase,        null: false, foreign_key: true
      t.references :staff,           null: false, foreign_key: true

      t.timestamps
    end
  end
end

Model

# == Schema Information
#
# Table name: purchase_main_staff_histories
#
#  id                :bigint           not null, primary key
#  purchase_id       :bigint           not null
#  changed_at        :datetime         not null
#  old_main_staff_id :bigint           not null
#  new_main_staff_id :bigint
#  changed_by_id     :bigint           not null
#  created_at        :datetime         not null
#  updated_at        :datetime         not null

class PurchaseMainStaffHistory < ApplicationRecord
  belongs_to :purchase
  belongs_to :changed_by,     class_name: 'staff'
  belongs_to :old_main_staff, class_name: 'staff'
  belongs_to :new_main_staff, class_name: 'staff', optional: true
  • 外部キーのカラム名は自動的に_idになる:通常のモデル名でリレーションを張る場合と同じ挙動なので、当然といえば当然ですが。。😅
  • リレーション宣言のbelongs_toの引数には_id不要:これも通常のモデル名でリレーションを張る場合と同じ挙動ですね。。
  • リレーションの宣言は相思相愛でなくてもいい:テーブル間でリレーション持たせる時は、モデルでお互いがお互いに宣言しなくてはならないのかと勘違いしていたが、参照する側が参照される側に対して宣言すればいい。今回のケースでは、リレーション先のStaffモデルからのhas_manyは今回は宣言する必要はないです。

View

セレクトボックスでstaffを選択できるようにします。

# app/views/purchase_main_staffs/_form.html.erb

<%= form_with(model: purchase, local: true) do |f| %>

  <label><%= Purchase.t(:main_staff) %></label>
  <%= f.select(:staff_id,
               @staffs_select_options,
               { include_blank: true }) %>

  <%= f.submit(t('views.common.submit'), class: 'btn btn-primary') %>
<% end %>

nullを許容するので、include_blankを入れてあげます。 @staffs_select_optionsは、staffの選択肢セットで、コントローラから渡します。

routes

resources :clients, param: :code do
  resources :purchases do
    resource :purchase_main_staffs, param: :purchase_code, only: %i[edit update]
  end
end

Controller

class PurchaseMainStaffsController < ApplicationController
  before_action :set_client
  before_action :set_purchase

  def edit
    @staffs_select_options = Staff.all.pluck(:name, :id)
  end

  def update
    new_main_staff = Staff.find_by(id: purchase_params[:staff_id])
    @purchase.change_main_staff_with_history!(
      Time.current,
      new_main_staff,
      current_staff
    )

    if main_staff_history
      redirect_to client_purchase_path(@purchase.client, @purchase),
                  notice: "担当スタッフを更新しました。"
    else
      @purchase = main_staff_history.purchase
      flash.now[:alert] = main_staff_history.errors.first if main_staff_history.errors.first.present?
      render :edit
    end
  end

  private

  def purchase_params
    params.require(:purchase).permit(:staff_id)
  end

  def set_client
    @client = Client.find(params[:client_id])
  end

  def set_purchase
    @purchase = @client.purchases.find(params[:purchase_id])
  end
end

PurchaseMainStaffsControllerクラスのコントローラを作って、@purchaseインスタンス変数に対してモデルメソッドchange_main_staff_with_history!を呼び出します。このメソッドは次に定義していきます。

Model method

class Purchase < ApplicationRecord

  def change_main_staff_with_history!(new_main_staff, current_time, current_staff)
    purchase_main_staff_histories.create!(
      changed_at: current_time,
      old_main_staff: main_staff,
      new_main_staff: new_main_staff,
      changed_by: current_staff
    )

    update!(staff_id: new_main_staff&.id)
  end

end

先ほどのinteractorで@purchaseインスタンスに対して履歴を作るように書いたので、Purchaseモデルにメソッドを定義してあげます。create!メソッドでレコードを作成して、自身の@purchaseに関しても外部キーのmain_staffを更新します。

これで実装完了です!

最後に

何か参考になれば幸いです。

kakudaisuke

kakudaisuke

Twitter X

IoT