iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Modern Web

初階 Rails 工程師的養成系列 第 29

Day29. Rails MVC 的 Model - 與資料庫聯絡的橋樑

  • 分享至 

  • xImage
  •  

礙於篇幅的關係,來不及介紹關於Rails MVC的所有全貌。在Day23介紹了基本的MVC操作,今天我們要來更深入與資料庫互通訊息的model層。

對於資料庫的行為不外乎是寫入資料、刪除資料、查詢資料。我們可以把資料庫想像成是excel表單,我們必須要對表單做操作,而model負責的除了負責透過Rails 層級的操作資料庫功能,也提供給開發者更簡便對資料庫操作的介面。

Column Names

首先先介紹,當我們創建了一個Model,我們如何看訂單的所有欄位

Order.column_names
#=> ["id", "customer_id", "created_at", ...]

#===== 找開頭為 discount 的欄位
Order.column_names.select{|_| _.start_with? 'discount'}
#=> ["discount_detail"]

#===== 找含有關鍵字 discount 的欄位
Order.column_names.grep(/discount/)

另外介紹annotated 這個 Gem,安裝並下了rails db:migrate 指令之後,就會在model將所有欄位以註解的方式呈現

gem 'annotate'

app/models/blog.rb ⬇️

# == Schema Information
#
# Table name: blogs
#
#  id         :bigint           not null, primary key
#  content    :string(255)
#  genre      :integer
#  title      :string(255)
#  created_at :datetime         not null
#  updated_at :datetime         not null
#  user_id    :bigint           not null
#
# Indexes
#
#  index_blogs_on_user_id  (user_id)
#
class Blog < ApplicationRecord
  belongs_to :user

  validates :title, :content, :genre, presence: true

  enum genre: {
    life: 0,
    casual: 1,
    technology: 2,
  }
end

我們在Day4提過#grep的用法,以及Array of Strings,而#grep用在這裡剛剛好,大家要記得回頭看?

InstanceMethod

除了使用欄位以外,我們也會在 model 新增實體方法。以下的例子為新增方法判斷折讓單號有值代表該筆退貨單折讓成功

class ReturnOrder < ApplicationRecord
  # 折讓成功
  define_method :allowance_success?, -> { invoice_allowance_number.present? }
end

下列為購物車的 model 來舉例和購物車相關的實體方法,以下為例子上面兩個方法的情境

  • changed_in_day? ➡️ 24小時內購物車沒有動會回傳true
  • subtotal ➡️ 購物車內商品*價錢的總和
class Cart < ApplicationRecord
  define_method :changed_in_day?, -> { cart_items.count.positive? && cart_items.pluck(:updated_at).max > 1.days.ago }

  define_method :subtotal, -> { cart_items.includes(:variant).sum{|item| item.variant.price * item.quantity} }
end

Orm

Rails 提供一些好用的語法,可以使我們不用寫SQL Statement。我們可以透過to_sql查看我們寫的orm是不是我們預期的Sql Statement,通常工程師都會看Sql Statement作為偵錯的依據

Order.all.to_sql
#=> "SELECT `orders`.* FROM `orders`"

以下為針對訂單做一系列Orm 語法介紹。為了簡單化,今天的Orm不會牽涉到一個以上的資料表

Order.all
#=> "SELECT `orders`.* FROM `orders`"

Order.where(status: 'processing')
#=> "SELECT `orders`.* FROM `orders` WHERE `orders`.`status` = 'processing'"

Order.where(status: ['processing', 'waiting'])
#=> "SELECT `orders`.* FROM `orders` WHERE `orders`.`status` IN ('processing', 'waiting')"

Order.where(status: ['processing', 'waiting']).pluck(:number)
#=> SELECT `orders`.`number` FROM `orders` WHERE `orders`.`status` IN ('processing', 'waiting')

#==== rails 裡面若 where 一個陣列代表 in 語法
Order.select(:number).where(status: ['processing', 'waiting'])
#=> SELECT `orders`.`number` FROM `orders` WHERE `orders`.`status` IN ('processing', 'waiting') LIMIT 11

#==== rails not 用法
Order.select(:number).where.not(status: ['processing', 'waiting'])
#=> SELECT `orders`.`number` FROM `orders` WHERE `orders`.`status` NOT IN ('processing', 'waiting') LIMIT 11

#==== rails or 用法
Order.where(status: 'processing').or(Order.where(payment_status: 'unpaid'))
#=> "SELECT `orders`.* FROM `orders` WHERE (`orders`.`status` = 'processing' OR `orders`.`payment_status` = 'unpaid')"

#==== rails count 用法
Order.where(status: 'processing').or(Order.where(payment_status: 'unpaid')).count
#=> SELECT COUNT(*) FROM `orders` WHERE (`orders`.`status` = 'processing' OR `orders`.`payment_status` = 'unpaid')
#=> 64

以上為Model的基本用法

Orm sum

另外我們分析一下Enumable#sum(Ruby) Orm#sum(Rails) 有什麼不同

Order.all.pluck(:price)
# SELECT `orders`.`price` FROM `orders`
# => [5, 325, 325, 6080, 2850, 88, 23200, 23200, 34700, 50850, 78400, 25800, 1425, 18880, 18880, 8800, 21800, ...] 

#=== ORM sum ===
Order.all.sum(:price)
# SELECT SUM(`orders`.`price`) FROM `orders`
# => 1283728 

#=== 資料全撈又會失敗 ===
Order.all.pluck(:price).sum
# SELECT `orders`.`price` FROM `orders`
#=> TypeError (nil can't be coerced into Integer)

ORM 的#sum只撈取一個欄位,且相加若為nil 也不會壞掉,而Enumable#Sum(Ruby) 是對陣列做處理的方法,若裡面的值含有nil,則會錯誤。因此如果我們使用Enumable#sum,我們必須使用compact, select(:itself),或類似的reducer 將陣列留下只省下數字,才能進行加法。

ClassMethod & Scope

一般的scope 用法與類別方法幾乎無異

class Order < ApplicationRecord
  def self.bar
    "bar"
  end
  
  scope :foo, -> { "foo" }
end

Order.foo #=> "foo"
Order.bar #=> "bar"

另外scope 與類別方法一樣,可以使用參數

class Order < ApplicationRecord
  scope :foo, ->(x) { "foo #{x}!" }
end

Order.foo(1) #=> "foo 1!"

慣例上,我們會用scope 作為對該 model 的查詢

class Order < ApplicationRecord
  # 還沒同步 pos
  scope :yet_not_sync_pos, -> { where(sync_pos_at: nil).where(status: %w[done returned]) }
  # 一個禮拜前
  scope :week_ago, -> { ransack(done_at_lt: Tool.appreciation_period.ago).result }
end

Order.yet_not_sync_pos.week_ago
#=> "SELECT `orders`.* FROM `orders` WHERE `orders`.`sync_pos_at` IS NULL AND `orders`.`status` IN ('done', 'returned') AND `orders`.`done_at` < '2021-09-20 07:42:54'" 

enum

enum的用法為內建的ActiveRecord::Enum,當我們宣告了一個enum方法,我們就可以使用enum 屬性。使用enum,我們可以 將型態存為Integer,以增加資料庫的速度,不過除了速度以外,重要的更是有一些 Rails 提供的方法可以使用,下列例子為enum的一些常見的使用方法

⭐️ enum 會送一些instance method

# models/customer.rb
class Blog < ApplicationRecord
  enum genre: {
    life: 0,
    casual: 1,
    technology: 2,
  }
end

# 單筆
blog = Blog.first
blog.technology?        #=> true
blog.casual?            #=> false
blog.life?              #=> false

⭐️ 若我們不用enum,而使用module 作為狀態的話也可以,但就沒有額外的helper可以使用

class Order < ApplicationRecord
  module Status
    UNPAID      = :unpaid       # 未付款
    PROCESSING  = :processing   # 處理中
    WAITING     = :waiting      # 已出貨
    DONE        = :done         # 已完成
    CANCELED    = :canceled     # 已取消
    RETURNED    = :returned     # 退貨/退款
  end
end

⭐️enum 會送scope

class ReturnOrder < ApplicationRecord
  # 退貨狀態: 申請退貨/已確認商品退款中/退款失敗/退貨失敗/已完成退貨
  enum status: %i(applied processing done refund_failed return_failed)
end

#========= 查詢申請中的表單 =========#
ReturnOrder.applied
#=> "SELECT `return_orders`.* FROM `return_orders` WHERE `return_orders`.`status` = 0"
#=== 等同於 Conversation.where.not(status: :applied)

⭐️ enum 作為常數 ➡️ 可以很方便地做下拉式選單的資料

ReturnOrder.statuses
#=> {"applied"=>0, "processing"=>1, "done"=>2, "refund_failed"=>3, "return_failed"=>4}

另外,enum 可以和很多對象做搭配

  • 透過轉陣列,將資料結構組成下拉式選單畫面
  • AASM ➡️ 有限狀態機
  • Ransack ➡️ Model 層級查找資料的 Gem

life cycle Hook

以下這些為model提供的判斷生命週期方法,可以藉由這些方法判別該筆資料位於哪一個生命週期。

以下使用 new_record?, persisted? 判斷這一筆是否並沒有被存進資料庫。

User.first.blogs.create.new_record?   #=> true
User.first.blogs.create.persisted?    #=> false

以下為 ActiveRecord 的生命週期

new record ➡️ 尚未寫入階段會判斷為true

blog = Item.new
blog.new_record? #=> true

persisted ➡️ 已寫入階段會判斷為true

blog.save
blog.persisted? #=> true

changed ➡️ 資料被改寫但尚未存進資料庫會被判斷true

blog.name = "other"
blog.changed? #=> true

destroyed ➡️ 資料被刪除但該筆紀錄,但還沒重整。因此該筆資料暫時存在在model。遇到這種情況則會判斷為true

blog.destroy
blog.destroyed? #=> true

delegate

delegate 也是Rails 常見的使用方式!以下面的程式碼為例,當我要取得訂單訂購人地址,可以使用 delegate

# models/customer.rb
class Customer < ApplicationRecord
  def customer_info
    # 顧客資訊
    [name, phone].join('-')
  end
end
  

# models/profile.rb
class Order < ApplicationRecord
  belongs_to :user

  delegate :customer_info, to: :customer
end

# 使用
order = Order.find(1)
order.customer_info    # 陳漢漢-0983168969

delegate & query object with scope

觀察 scope 的底層,我們發現scope可以搭配call使用,衍伸出以下用法。

module Orders
  class MoreThanTenThousandQuery
    class << self
      delegate :call, to: :new
    end

    def initialize(relation = Order.all)
      @relation = relation
    end

    def call
      @relation.where(price: 10000..)
    end
  end
end

models/order.rb ,將原本應該要填入Proc的地方填入QueryObject

class Order < ApplicationRecord
  scope :m, Orders::MoreThanTenThousandQuery
end

並在 Rails Console 使用

 Order.m
#=> "SELECT `orders`.* FROM `orders` WHERE `orders`.`price` >= 10000"

callback & validation

驗證和回呼是 ActiveRecord 裡面相當重要的一環。

在驗證方面,開發者可以調用Rails 內建的驗證,或者寫自定義的驗證。這些驗證此驗證與SQL Constraint不一樣,此為Model層級的驗證,而SQL ConstraintSQL層級的驗證。若兩者都加當然最好,這樣一來就多一層的保證

⭐️ 以下為Rails 內建的檢查值是否為空的驗證

class Blog < ApplicationRecord
  belongs_to :user

  validates :title, :content, :genre, presence: true
end

⭐️ 以下為 Rails 內建檢查是否為數字的驗證

class Order < ApplicationRecord
  validates :phone_area_code, length: { minimum: 2, maximum: 2 }, numericality: true
  validates :phone, length: { minimum: 6, maximum: 8 }, numericality: true
end

⭐️ 以下為Rails 內建對 Email & 網址列的驗證

class Customer < ApplicationRecord
  validates :personal_website, format: { with: URI::regexp(%w(http https)) }
  validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
end

⭐️ 另外,我們也可以寫自定義的方法

class Invoice < ApplicationRecord  
  # 驗證統一編號
  validate :valid_e_gui_tax_number
  # 驗證手機載具
  validate :valid_e_gui_carrier
  
  private

  # 驗證統一編號
  def valid_e_gui_tax_number
    return if buyer_ban.blank?

    return errors.add(:buyer_ban, '需為8位數') unless Validator::InvoiceTaxid.at_least_8_digits?(buyer_ban)

    errors.add(:buyer_ban, '不合法') unless Validator::InvoiceTaxid.valid_taxid?(buyer_ban)
  end

  # 驗證手機載具
  def valid_e_gui_carrier
    return if /\A[\/][0-9A-Z+-\.]{7}\z/.match?(carrier_id) || carrier_id.blank?

    errors.add(:carrier_id, '不合法')
  end
end

對應的驗證為 ⬇️

module Validator
  class InvoiceTaxid
    def self.at_least_8_digits?(taxid)
      taxid.match?(/\A\d{8}\z/)
    end

    def self.valid_taxid?(taxid)
      result = []
      taxid_array = taxid.split('')
      num_array = [1, 2, 1, 2, 1, 2, 4, 1]
      taxid_array.zip(num_array) { |a, b| result << a.to_i * b }

      sum = 0
      result.each do |elm|
        sum += elm.divmod(10).inject { |s, i| s + i }
      end

      ((sum % 10 == 0) || ((sum % 9 == 9) && (taxid[5] == 7)))
    end
  end
end

Rails中存取資料的過程區分不同階段,不同的階段組成一個生命週期,Rails 提供了一些方法,使我們可以在各個生命週期中,做我們想要做的事情,而每一個階段都稱為不同的回呼,英文稱作callback

回呼的部分,可以參考高見龍的文章。這裏只簡單介紹,當我們使用的一些Rails 的存取方法,有些會經過回呼、有些不會,以下舉例update, update_attribute 的用法

update             #=> 經過驗證、回呼
update_attribute   #=> 沒有經過驗證、回呼

經過回呼或驗證的存取,會因為經過 ActiveRecord 設定的各種生命週期,也就是Rails的驗證與回呼,因此會跑得比較慢,但資料的存取比起直接對資料庫存取,可以更確保該筆Record更完整。

Store Attributes (JSON)

Postgres 比起 Mysql 處理 Json, Array 更有優勢,Drifting Ruby 的某一篇 Episode 把處理 Json 的內容寫得很好。不過這裡指針對 Store Attributes 來講

如何在model裡面處理json 型態的資料是門藝術,Store Attributes幫我們做好一部分的工作。跟大家介紹之前在某個任務中,為了做時間線的效果,我將切換狀態的同時,將資料存進 Json 裏面,並用Store Attribute 寫好的方法,來更輕鬆的取值。

以下為範例程式碼

# migration file (mysql)
class AddShippingStatusRecordToOrders < ActiveRecord::Migration[6.1]
  def change
    add_column :orders, :shipping_status_record, :json, comment: '物流時間紀錄'
  end
end

# models/order.rb
class Order < ActiveRecord::Base
  include ApplicationHelper
  before_create :shipping_updated

  module ShippingStatus
    READY = "ready"
    PARTIAL = "partial"
    SHIPPED = "shipped"
    ARRIVED = "arrived"
    RETURNED = "returned"
    RECEIVED = "received"
  end

  # 物流時間紀錄
  store :shipping_status_record,
        accessors: Order::ShippingStatus.constants.map { |c| Order::ShippingStatus.const_get(c).to_sym },
        coder: JSON, suffix: :at
  ...
  # 貨運時間畫押日期
  def shipping_updated
    self.ready_at = DateTime.now if self.ready_at.nil?

    if self.shipping_status_changed?
      if self.shipping_status == Order::ShippingStatus::SHIPPED
        self.shipped_at = DateTime.now
      end

      self.shipment_updated_at = DateTime.now

      # [:shipped, :ready, :partial, :arrived, :returned, :received]
      return unless self.shipping_status.in? Order.stored_attributes[:shipping_status_record].map(&:to_s)

      # 紀錄時間動態方法
      self.send(:"#{self.shipping_status}_at=", DateTime.now.strftime("%F"))
    end
  end

  ...

end

首先我們在Order開了一個shipping_status_record 的欄位,並且在資料被創建前 before_create的回呼存取json值,並且這些存取的值我們可以使用利用store 這個方法將[:shipped, :ready, :partial, :arrived, :returned, :received] 轉為實體可以使用的方法

order.shipped_at
order.ready_at
order.partial_at 
order.arrived_at
order.returned_at
order.received_at

結論

這章所介紹的內容是Model基本中的基本的觀念,這些基礎身為 Rails 的工程師都應該要會。

  • Enum 一定要會用
  • Instance Method 一定要會
  • ClassMethod & Scope 也一定要會
  • 驗證跟回呼在Rails 佔了舉足輕重的地位,一定要會
  • Store Attributes 這種好用的工具一定要知道
  • delegate 不會沒關係,但偶爾會在專案看到這種寫法

Rails 還有許多用法沒有提到,像是

  • bang的用法(加!) ➡️ 會引發Raise

    搭配 ActiveRecord::Base.transaction 區塊一連串存取動作的某一動失敗時資料庫狀態全部復原

  • ModelSTI, Polymorphic

  • counter_cache

明天會介紹關聯,以及基本的has_many, has_one, belongs_to 的用法

參考資料


上一篇
Day28. Rails 搭配 DataTable 寫出完美的列表頁
下一篇
Day30. Model 與關聯 - preload, join, includes 一次釐清
系列文
初階 Rails 工程師的養成34
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言