0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

formはないけどDBに保存したい時とFat Model, Skinny Controllerの原則を意識

Posted at

ユーザーからの入力formはないけどDBに保存したい場合

こういう場面ありますよね。
例えば今回説明で使う例としてorderが保存されるとorder_detail保存されるようにします。

流れで言うと、formで注文が確定したらそのまま一緒に注文詳細が確定される感じです。
ただ、そのまま保存するのではなく注文詳細は買った当時の情報を保存するようにしています。
そうする事で、商品名が改名や値段もしくは商品自体削除されてしまった場合でも買った当時の注文データが保存すると言うわけです

ER図

今回出てくる奴ら
カラムはPKとFKしか書いてません
スクリーンショット 2023-06-25 11.03.53.png

まずはモデルから

order.rb
class Order < ApplicationRecord
  belongs_to :user
  has_many :order_details, dependent: :destroy
  has_many :products, through: :order_detail, dependent: :destroy

  
  validates :postal_code, presence: true
  validates :address, presence: true
  validates :apartment_name, presence: true

  scope :latest, ->(limit = 10) { order(created_at: :desc).limit(limit) }


  def total_billing_price
    order_details.inject(0) do |sum, order_detail|
      sum + order_detail.subtotal
    end
  end
end

次にコントローラー

orders_controller.rb
class OrdersController < ApplicationController
  def create
    @total_price = current_user.total_cart_price
    @cart_products = current_user.cart_products
    @order = Order.new(order_params)
    @order.user_id = @current_user.id

    # カートが空で商品注文するのはおかしいため
    if @cart_products.empty?
      flash[:danger] = 'カートが空です。商品を追加してから購入してください。'
      render 'cart_products/index', status: :unprocessable_entity
      return #  ここで処理を止める
    end

    ActiveRecord::Base.transaction do
      if @order.save
        @cart_products.each do |cart_product|
          @order.order_details.create!(product_id: cart_product.product_id,
                                       amount: cart_product.amount,
                                       price: cart_product.product.price,
                                       product_name: cart_product.product.name)
        end
        @cart_products.destroy_all
      else
        render 'cart_products/index', status: :unprocessable_entity
        return # AbstractController::DoubleRenderError防止(renderとreditect_toが同時に呼ばれてしまう)
      end
    end
    flash[:success] = 'ご購入ありがとうございます'
    redirect_to products_path
  end

  private

  def order_params
    params.require(:order).permit(:postal_code, :address, :apartment_name)
  end
end

あらら、コントローラに結構責務がよってしまっています。
このままでも動くには動くのですがテストがしにくいのと再利用性がないと不便な所が多々あるので
Fat Model, Skinny Controllerの原則を意識してみます。

※current_userというメソッドはapplication_controller.rbで定義しています。

今からmodelにかなり責務を渡します。

modelさん責務を全うしてください。(DB操作はコントローラではなく基本modelに投げる)
まずはカートが空なのかどうかを確認するメソッドをモデルに任せましょう。
現在をログインしているuserのカート確認したいのでuserモデル書きます

user.rb
def cart_empty?
  cart_products.empty?
end

至ってシンプルですね。
UserモデルはCartProductモデルとアソシエーションしています。
なのでこのようなシンプルな書き方ができます。
内容は、カートの中身が空ならtrueで中身があればfalseになります。
メソッド名は自分で考えました。
カート空ですか?みたいな感じです、真偽値を返すメソッドは?をつける習慣があるので自分も真似してみました。

余談ですが、逆に中身があればtrueを返すpresent?メソッドと言うものもあります。

次にどうやってコントローラに渡すのか?

orders_controller.rb
if current_user.cart_empty?
  flash[:danger] = 'カートが空です。商品を追加してから購入してください。'
  render 'cart_products/index', status: :unprocessable_entity
  return # AbstractController::DoubleRenderErrorが出るためここで処理を止める
end

current_userはapplication_controllerの内容でmodelでは使えないのでコントローラに書いてます

order関連なのになぜUserモデルに書くの?
このようにuser関係.メソッド名と続いてるのでUserモデルにメソッドを書きます

後は、フラッシュメッセージでなぜエラーが出たのかをユーザーに教えます。
renderで遷移せずに同じページにとどまることで入力された値が消えないで済みます。
今回の場合カート一覧と注文するformが同じページにあるので'cart_products/index'
このような書き方になってます。

returnは次の処理に行かないようにしています。(処理はまだ続きます)

次はDB操作が複数ある部分をmodelに任せましょう

ここで今回の本命、入力formはないけどDBに保存したい部分を叶える部分です

orders_controller.rb
# 購入確定したらorder_detailsテーブルに買った時の注文の詳細を保存してカートの中身は全削除
def order_confirm(order)
  ActiveRecord::Base.transaction do
    if order.save
      cart_products.each do |cart_product|
        order.order_details.create!(product_id: cart_product.product_id,
                                    amount: cart_product.amount,
                                    price: cart_product.product.price,
                                    product_name: cart_product.product.name)
      end
      cart_products.destroy_all
    end
  end
end

Order情報がDBに保存できたと同時に、現在ログインしているユーザーのカートの中身を一つずつ確認し注文詳細のテーブルのカラムに沿って保存しています。

このようにしてform入力なしでDBに保存することができました!

また、別テーブルに永久保存させたい内容のカラムを作ってあげる事で将来商品が編集されたり削除されたとしても
買った当時のデータのまま保存できます。

もしproduct_idとamount(購入確定時の数量)だけだったらproduct_idでたどった商品名と
product_idでたどったpriceなのでその時(編集された場合、買った当時ではない)の商品名と値段が表示されてしまいます。
商品が削除されていたら、、、、表示すらされません。

本記事とは関係ないので興味ない方は飛ばしてください
ActiveRecord::Base.transaction
この部分はトランザクションを使って整合性を保持しています。
(最近覚えたてなのでアウトプットします)
万が一注文が確定したのにそこから不具合があって処理が止まってしまった場合、
注文詳細は作られずに注文が確定してしまいます。
またカートの中身が前削除される前に不具合が起きた場合、
買ったのにカートの中身が残っているという奇妙な現象も起きます。
それだと、不整合なのでロールバックという技を使ってトランザクションの中身の処理が途中で止まってしまった場合、処理を続けないで振り出しに戻ることができます。

またもう一つ、ロックという技もあり、例えば、後1個で売り切れ!という商品がほぼ同時に2人のユーザーから注文があったとします。
トランザクションを使わないでいると、、、どっちも注文できてしまいます。
ですが実際は商品は1つだけ。非常に良くない状況になってしまうというわけです。

ロックを使えば片方は注文処理を行わずに待機します。片方が処理が終わってそこでもう片方の人は
商品が売り切れという表示がされ「売り切れなんだなぁ」と知ることができます。

完成したcontorller

orders_controller.rb
class OrdersController < ApplicationController
  def create
    @total_price = current_user.total_cart_price # renderで戻る時に使う
    @cart_products = current_user.cart_products # renderで戻る時に使う
    @order = Order.new(order_params)
    @order.user_id = @current_user.id

    # カートが空で購入するのはおかしいため
    if current_user.cart_empty?
      flash[:danger] = 'カートが空です。商品を追加してから購入してください。'
      render 'cart_products/index', status: :unprocessable_entity
      return # AbstractController::DoubleRenderErrorが出るためここで処理を止める
    end

    if current_user.order_confirm(@order)
      flash[:success] = 'ご購入ありがとうございます'
      redirect_to products_path
    else
      render 'cart_products/index', status: :unprocessable_entity
    end
  end

  private

  def order_params
    params.require(:order).permit(:postal_code, :address, :apartment_name)
  end
end

26行あったのが16行になりだいぶスッキリしました!
コントローラーはリクエストを受け取り、適切なレスポンスを生成する役割に集中できています。
これにより、コントローラーとモデルの責任が明確に分離され、テストやメンテナンスが容易になります。
また、コントローラーがスリムになることで、アクションの目的が一目で分かり、可読性も向上します

個人的感想なのですが、プログラミングは細かく細かく作るのがいいみたいですね。
管理しやすいから?テストしやすいから?目的がわかりやすいから?だと思います。
まだ実務もやったことない初学者ですがそう思いました。

では最後にサクッと注文詳細モデルとコントローラについて

order_detailモデル

order_detail.rb
class OrderDetail < ApplicationRecord
  belongs_to :order
  belongs_to :product
  
  validates :amount, presence: true, numericality: { only_integer: true, greater_than: 0 }
  validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 }
  validates :product_name, presence: true

  # ひとつの商品の合計
  def subtotal
    price * amount
  end
end

order_detailsコントローラ

order_controller.rb
module Admin
  class OrderDetailsController < BaseController
    skip_before_action :login_required, only: %i[index show]
    def index
      @orders = Order.latest
    end
  
    def show
      @order = Order.find(params[:id])
      @order_details = OrderDetail.where(order_id: params[:id])
      @total_price = @order.total_billing_price
    end
  end
end

OrderDetailsControllerではビューに移すだけです
一覧画面は最新順に並び替えています
データ加工もDBに関連することなのでモデルに書きます。

詳細ページは、注文の主キーを取得し注文詳細の一覧を表示します
@total_priceは注文詳細の値段の合計金額を表示します。

スクリーンショット 2023-06-25 9.46.57(2).png

bin/rails consoleを使い確認しましょう。

Order.find(params[:id])
今回はorder_id: 2の注文情報を例としてます
orderした人の住所や郵便番号や名前が取得できます

OrderDetail.where(order_id: params[:id])
OrderDetailのorder_idが2の注文詳細が取得できます。
今回の場合、4つ商品の注文詳細の情報が確認できるというわけです。

そしてもう一つ商品が編集された場合も見ていきましょう
スクリーンショット 2023-06-25 10.17.44(2).png

order_idが1と9のものを取得しました。

product_idに注目してください。

商品名と値段が変わっても当時の情報が保存されているのがわかると思います。

商品名は気にしないでくださいw

order.total_billing_priceの部分

order.rb
def total_billing_price
  order_details.inject(0) do |sum, order_detail|
    sum + order_detail.subtotal
  end
end

注文詳細の合計金額の計算です。

injectを使い引数に0を渡すことで初期値が0になります。
よって、最初は0 + 1つ目の商品の金額(1個の価格と個数を掛け算した結果)
次に1つ目の商品の金額 + 2つ目の商品の金額 1つ目と2つ目の商品の金額 + 3つ目の商品の金額
このような処理が行われます。order_detail.subtotalはOrderDetailモデルに書いてあります。

これにてformに入力しないでDBに保存でき、ビューに表示する事ができました!

最後に

最初は全然理解できませんでしたが、ようやく少しずつ理解ができてきました。
これからもどんどんアウトプットして理解を深めていきます

お疲れ様でした!!!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?