礙於篇幅的關係,來不及介紹關於Rails MVC的所有全貌。在Day23介紹了基本的MVC
操作,今天我們要來更深入與資料庫互通訊息的model
層。
對於資料庫的行為不外乎是寫入資料、刪除資料、查詢資料。我們可以把資料庫想像成是excel
表單,我們必須要對表單做操作,而model
負責的除了負責透過Rails
層級的操作資料庫功能,也提供給開發者更簡便對資料庫操作的介面。
首先先介紹,當我們創建了一個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
用在這裡剛剛好,大家要記得回頭看?
除了使用欄位以外,我們也會在 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
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
的基本用法
另外我們分析一下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
將陣列留下只省下數字,才能進行加法。
一般的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
的用法為內建的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 可以和很多對象做搭配
以下這些為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 也是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
觀察 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"
驗證和回呼是 ActiveRecord
裡面相當重要的一環。
在驗證方面,開發者可以調用Rails
內建的驗證,或者寫自定義的驗證。這些驗證此驗證與SQL Constraint
不一樣,此為Model
層級的驗證,而SQL Constraint
為SQL
層級的驗證。若兩者都加當然最好,這樣一來就多一層的保證
⭐️ 以下為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
更完整。
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 的工程師都應該要會。
Rails
佔了舉足輕重的地位,一定要會Store Attributes
這種好用的工具一定要知道delegate
不會沒關係,但偶爾會在專案看到這種寫法Rails
還有許多用法沒有提到,像是
bang
的用法(加!
) ➡️ 會引發Raise
搭配 ActiveRecord::Base.transaction
區塊一連串存取動作的某一動失敗時資料庫狀態全部復原
Model
的STI
, Polymorphic
counter_cache
明天會介紹關聯,以及基本的has_many
, has_one
, belongs_to
的用法