Model 與 Table 的 Naming Conventions
Model : 兩個單字以上不使用 _
連接, 並且開頭字母大寫, ex: BookClub
Table : 兩個單字以上使用 _
連接, ex: book_clubs
Model / Class Model 檔名 Table / Schema
--------------------------------------------------------
Article article.rb articles
LineItem line_item.rb line_items
Deer deer.rb deers
Mouse mouse.rb mice
Person person.rb people
外連鍵命名 : item_id, order_id
CRUD
Read
User.find(3)
User.find(3, 4, 5)
User.first
User.first(5) # 取前5個
User.last
User.all
User.count
User.limit(10)
User.where(name: "ash").order(status: desc).limit(10) # complex
User.where('name LIKE ?', "%Bob%") # Like
User.order(:created_at) # orderd by created_at
User.order(created_at: :asc) # orderd by created_at ASC
User.order("user_extension.company desc") # SQL
User.order("RAND()") # random
User.order("age DESC, RAND()") # 先遞減再 random
User.column_names # 取得欄位名稱
User.where("DAY(created_at) = ?", DateTime.now.day) # 本日
User.where("MONTH(created_at) = ?", DateTime.now.month) # 本月
User.where("YEAR(created_at) = ?", DateTime.now.year) # 本年
Select 某個欄位挑出並 Alias
User.joins(:user_groups).select('user_groups.name AS group_name')
AND
where 預設就是 AND 了
你也可以用另一種寫法 :
User.where('`age` >= ? AND `age` <= ?', 10, 20)
OR
User.where('`grade` = ? OR `level` = ?', 80, 3)
注意! 使用 ` 括起來
IN
@users = User.where(name: [value1, value2]).all
> SELECT * FROM users WHERE name IN (value1, value2, and so on...);
LIKE
Product.where("name LIKE ?", "%#{@key_words}%")
無法 LIKE 的關鍵字 \
%
_
是否有改變 attribute
person = Person.find_by_name('Uncle Bob')
person.changed? # => false
person.name = 'Bob'
person.changed? # => true
person.name_changed? # => true
person.name_was # => 'Uncle Bob'
person.name_change # => ['Uncle Bob', 'Bob']
Create
先 new 再 save
u = User.new
u.name = 'Jex'
u.save
u = User.new(name: 'Jex')
u.save
Create
User.create(name: 'Jex', ages: '26')
沒有的話就新增
user.profile.where(user_id: user.id).first_or_create
Update
以下這幾種 update 方法會有不同的 validation 及 callback 行為, 請參考本文下 Validations & Callbacks 的整理圖表
先改再 save
user.name = 'Jex'
user.save
user.attributes = { name: 'Jex' }
user.save
Update
user.update('name', 'Jex')
update_attribute(name, value) 更新單一欄位
user.update_attribute('name', 'Jex')
update_attribute(attributes) 更新多個欄位
user.update_attributes(name: 'Jex')
update_column(name, value) 更新單一欄位
user.update_column('name', 'Jex')
update_columns(attributes) 更新多個欄位
user.update_columns(name: 'Jex')
+1 / +n 的簡潔寫法
a.increment!(:case_count) # case_count += 1
a.increment!(:case_count, 3) # case_count += 3
Delete
刪除單筆
User.find(3).destroy
刪除全部
User.destroy_all
有 !
與沒有的差別(i.e. create
vs create!
)
create 失敗時不會擲出 exception, 但 create! 會 (Raises a RecordInvalid error if validations fail)
create
create!
save
save!
update
update!
Validations & Callbacks
Validation examples
# 只要是數字就行
validates :price, numericality: true
# 特定範圍內的值
validates :price, inclusion: { in: 1..999999999, message: '必須 > 0' }
in: [true, false]
in: %w( male female )
# 除了特定範圍內的值
validates :price, exclusion: { in: 1..10 }
# 大於 等於 小於
validates :price, numericality: {
greater_than: 0 # 大於
less_than_or_equal_to: 100 # 小於等於
less_than_or_equal_to: :original_price # 小於等於 "某個欄位的值"
greater_than_or_equal_to: 100 # 大於等於
}
# 限制字串長度
validates :name, length: {minimum:2, maximum: 20}
# 必填
validates :name, presence: true
# Custom error message
validates :name, presence: { message: 'been eaten' }
# Regex
validates :name, format: { with: /.*/ }
# on: create
validates :email, format: { with: /\/i, on: :create }
# using a string:
validates :name, presence: true, if: 'name.present?'
# using a Proc:
validates :email, presence: true, if: Proc.new { |user| user.approved? }
# using a method:
validates :address, presence: true, if: :some_complex_condition
def some_complex_condition
true
end
# using a lambda 假設你希望某個 enum 的值有存在才驗證這個,可以這樣寫
validates :discount, inclusion: { in: 0.00..0.99 }, if: lambda { self.enum_discount? }
當 validate 失敗時取得 Error Messages
在 controller 可以用以下截取錯誤訊息
@user.errors.full_messages.join(',')
Custom validation / Custom errors message
如果有個 enabled 欄位, 判斷是否為 0 或 1, 錯誤的話給錯誤訊息
validate :check_enabled
def check_enabled
if ! enabled.to_i.between?(0,1)
errors.add(:enabled, 'Error message')
false # 當錯誤發生時要記得一定要回傳 FALSE
end
end
即使在 controller 也可以
@user.errors.add(:email, "Not valid")
@user.errors[:email] << "Not valid"
幾種 update 方法中不同的 validation 及 callback 行為
- save
- update(attributes)
- Validation and Callback 都會執行
- updated_at 會更新
- update_attributes(attributes)
- Validation 不會執行
- Callback 會執行
- updated_at 會更新
- update_attribute(name, value)
- update_columns(attributes)
- 直接執行 SQL statement
- Skip Validation and Callback
- updated_at 也不會被 update
- update_column(name, value)
Skip callback
attr_accessor :skip_callbacks
after_create :assign_role, unless: :skip_callbacks
user.skip_callbacks = true
user.save
Skip validation
user.save(validate: false)
針對特定情況才 validate
attr_accessor :need_validation
validates :real_name, presence: true, on: :update, if: :need_validation
當 update 需要 validate 時指定 need_validation: true
就會執行 validate 了
@user.update(real_name: 'Jex Lin', need_validation: true)
Custom field 驗證
有時候會有一些特殊需求, 例如需要一個欄位, 但這些欄位只在 model 的 before_create 前做處理才使用到, 不會存在在 DB 裡, 也因為會需要 validation, 所以放在 model
models/post.rb
attr_accessor :role
validates :role, presence: true, on: :create
限制只有在 create 才會做 validate 驗證, 否則 update 也會觸發到
controllers/posts_controller.rb
# 在 create 前的參數取得要允許 role
params.require(:post).permit(:title, :content, :role)
views/posts/new.html.erb
<%= f.input :role, input_html: { class: 'form-control' } %>
before_* / after_*
If any return false then everything stops.
before_validation
after_validation
before_save
before_save
before_create
after_create
before_update
after_update
before_destroy
after_destroy
強制存的 float 只能有一位小數
before_save do
if self.price_changed?
self.price = self.price.round(1)
end
end
在每次儲存前檢查特定欄位是否有變動,有的話記 flag
before_save do |f|
fields = [
'name',
'email',
]
fields.each do |field|
if f.send("#{field}_changed?")
f.edit_flag = true
break
end
end
end
before_destroy 刪除前檢查是否有其他關聯
category.rb :
has_many :products
before_destroy :check_for_products
def check_for_products
if products.any?
errors.add("無法刪除", "此類別下還有其他商品")
false
end
end
Associating models
through 用法
models/user.rb
has_many :groups througth => user_groups
User_groups Table 關聯 User.id 及 Group.id
User.groups 會藉由 through 取得 User 擁有的 Groups
官方 example
class Physician < ActiveRecord::Base
has_many :appointments
has_many :patients, through: :appointments
end
class Appointment < ActiveRecord::Base
belongs_to :physician
belongs_to :patient
end
class Patient < ActiveRecord::Base
has_many :appointments
has_many :physicians, through: :appointments
end
如果 User 與 Translator 的關係為 1 對 1, TranslatorLanguage 而是用 user_id 的話, 當 Translator 與 TranslatorLanguage 以 user_id 關聯寫法
Translator
has_many :translator_languages, primary_key: 'user_id', foreign_key: 'user_id'
TranslatorLanguage
belongs_to :translator, primary_key: 'user_id', foreign_key: 'user_id'
就可以直接用 Translator.user_id 與 TranslatorLanguage.user_id 關聯
Translator.first.translator_languages
TranslatorLanguage.first.translator
如果 Order.uuid 與 OrderItem.order_uuid 關聯
假設 OrderItem 沒有 order_id,關聯必須這樣寫
Order :
has_many :order_items, primary_key: 'uuid', foreign_key: 'order_uuid'
使用 Order.uuid 去關聯 OrderItem.order_uuid
OrderItem :
belongs_to :order, primary_key: 'uuid', foreign_key: 'order_uuid'
關聯回去
primary_key
: 是指自已的 key; foreign_key
: 是指要關聯到那個表的 key
ThirdCategory.id 與 Product.category_id 關聯
Product
belongs_to :third_category, foreign_key: 'category_id'
ThirdCategory
has_many :products, foreign_key: 'category_id'
可以雙向關聯
Product.first.third_category
ThirdCategory.first.products
FirstCategory 透過 SecondCategory 關聯 ThridCategory
FirstCategory
has_many :second_categories
has_many :third_categories, through: :second_categories
SecondCategory
belongs_to :first_category
has_many :third_categories
ThridCategory
belongs_to :second_category
includes with where
Article.includes(:comments).where(comments: { visible: true })
User.includes(:posts).references(:posts).where('posts.author = ?', 'Jex')
User.includes(:posts).references(:posts).where('posts.id IS NOT NULL')
class_name : has_many 不一定要跟 class name 一樣
has_many :profile
等同於
has_many :info, class_name: 'Profile'
Join + where
User.all.joins(:user_languages).where(user_languages: { from: 1, to: 2 } )
或
User.all.joins(:user_languages).where('`from` = ? AND `to` = ?', 1, 2)
LEFT JOIN
joins('LEFT JOIN categories AS b ON (a.id = b.parent_id)')
Alias table name
Category.from('categories AS a').where('a.parent_id = ?', 0)
將結果的 AssociationRelation 集合截取特定欄位
顯示 DB 真實的值
Book.pluck(:type)
> [1, 2, 3]
Book.pluck(:type, :name)
如果有 Enum 會自動被轉換, 兩者效果相同 :
Book.all.map(&:name)
或
Book.collect(&:type)
> [tech, food, sport]
scope
Scope (基本用法)
scope :sold, -> { where(state: 'sold') } # 一般用法
scope :teenager, -> { where("age >= 13 AND age <= 19") } # 下 where SQL
scope :recent, ->{ where(published_at: 2.weeks.ago) } # 使用 proc object 來避免 date 固定的問題
scope :recent, -> { order("created_at desc").limit(3) } # Order 完再 Limit
scope :recent_red, ->{ recent.where(color: 'red') } # 處理完再 where
scope :unpublished, -> {self.published} # 使用其他 scope
可傳入參數
scope :starts_with, -> (name) { where("name like ?", "#{name}%")}
@products.starts_with(params[:starts_with])
Default scope
default_scope { is_admin } # 也可以是 enum 的值
default_scope {where(deleted_at: nil)}
# 使用
Post.all # Fires "SELECT * FROM posts WHERE deleted_at IS NULL"
Post.unscoped.all # Fires "SELECT * FROM posts"
Join scope
scope :feature_products, -> { joins(:products).merge(Product.features) }
Join 完要注意的是要記得 includes
@c = Category.feature_product
@c.products 會變成 category 重新撈底下的 products
應該改成
@c = Category.feature_product.includes(:products)
就會撈 join 裡面的 products 了
Includes scope
# 傳入 where 的值 `Person.parent_last_name('John Smith')`
scope :parent_last_name, ->(name) { includes(:parents).where(last_name: name) }
Includes nested relationships
Category 可以直接撈 product, 但 product_specs 不行,product_specs 只有跟 product 關聯, 所以用下面的方式 includes products 及 product_specs
Category.first.includes(products: [:product_specs])
joins 與 includes 的差別
joins
a = User.joins(:translator)
=> SELECT "users".* FROM "users" INNER JOIN "translators" ON "translators"."user_id" = "users"."id"
a[1].translator
Translator Load (0.3ms) SELECT ...(略)
看這個 SQL 結果知道當兩邊 TABLE 的都有資料的 user (user_id 一樣) 才放入集合, 所以它是沒有關聯到 translator 的, 如果 each print translator 的資料的話會有 N+1 query 的問題
includes
a = User.includes(:translator)
User Load (1.3ms) SELECT "users".* FROM "users"
SELECT "users".* FROM "users"
Translator Load (2.3ms) SELECT "translators".* FROM "translators" WHERE "translators"."user_id" IN (1, 2, ...(略)... )
這邊會一次將需要的 translator 資料都取出來, 但如果某個 user 沒有 translator 他仍然會在集合裡, 只是要取 translator 時是 nil
a[1].translator
就會直接從 cache 取, 不會再下 query 了
先 joins 後 includes 結果不符合預期問題
假如我們要先取出 group_id = 3 的資料, 然候再用 includes 避免 each 有 n+1 query
users = User.with_role(:user)
users = users.joins(:user_groups).where(user_groups: { group_id: 3 })
users = users.includes(:user_groups)
但會發現最後 user_groups 只會剩 group_id = 3 那筆, 最後一行不使用 includes 改成 preload, 即可避免
users = users.preload(:user_groups)
eager_load vs includes vs preload
includes
is similar to eager_load but smarter
preload
User.preload(:friends).where(id: 1)
SELECT * FROM users WHERE id = 1
SELECT * FROM friends WHERE user_id in ( .... )
eager_load
User.preload(:friends).where(id: 1)
SELECT * FROM users LEFT JOIN friends ON user_id = user_id WHERE id = 1
eager_load (erroneous example)
User.preload(:friends).where(id: 1).where(friends: { name: 'Wang'})
SELECT * FROM users LEFT JOIN friends ON user_id = user_id WHERE id = 1 WHERE friends.name = 'Wang'
In this case, we can’t use eager_load
, because the second where
is for friends
Enum
models/users.rb
enum gender: {female: 0, male: 1}
# 也等於 : enum gender: [:female, :male]
撈出來就能直接取得對應的值
顯示 enum 對應表 (也就是 model 裡設定的 enum)
User.genders
=> {"female"=>0, "male"=>1}
直接取得對應後的結果
u = User.find(1)
u.gender
=> "female"
如果指定 gender 一個不存在在 enum 的值會引發錯誤, ex: u.gender = 131
更可以直接取得值
u.male?
=> true
where 使用 enum 對應
User.where(gender: User.genders[:male])
那麼 view 該如何呈現呢?
# simple_form 版
<%= f.input :gender, as: :radio_buttons, collection: User.genders.keys %>
某些時候需要真實的值而不是 Enum 轉義過的值
User.find(4).read_attribute('status')
models/user.rb
enum gender: {female: 0, male: 1}
def self.genders_i18n(hash = {})
genders.keys.each { |key| hash[I18n.t("genders.#{key}")] = key }
hash
end
locales/zh-TW.yml
zh-TW:
genders:
female: '女'
male: '男'
到 Rails consle 結果是否正確
I18n.locale = 'zh-TW'
User.genders_i18n
=> {"女"=>"female", "男"=>"male"}
view : 顯示 radio button (with simple_form)
<%= f.input :gender, as: :radio_buttons, collection: User.genders_i18n %>
view : 顯示 gender + i18n
Gender: <%= t('genders.' + current_user.gender) %>
Enum 同值問題, 用 prefix 解決
Enum 預設不支援同值, 如果有兩個不同 column 但值相同, 會引發錯誤
ex :
hash = { private: 0, public: 1 }
enum post: hash
enum name: hash
因為 enum 可以這樣用, 如果有兩個一樣值的 enum, 就會產生問題, 不能明確指的是哪一個 column
a.post = 1
a.public?
=> true
解決方法 : 加上 prefix
己經有人發現這個問題也己經修復了, 使用 enum_prefix
參數即可解決,
但目前 Rails 4.2.3 版還沒有, 只能等下一版發佈才能使用,
在那之前只能先用其他方式達成 :
status = { private: 0, public: 1 }
enum post: Hash[status.map{|k,v| ["post_#{k}",v]}]
enum name: Hash[status.map{|k,v| ["name_#{k}",v]}]
transaction
確保在 transaction block 裡的程式都成功,最後才會寫進資料庫
最經典的例子 - A 匯款給 B
ActiveRecord::Base.transaction do
david.withdrawal(100)
mary.deposit(100)
end
Different ActiveRecord classes in a single transaction
雖然 transaction 是被 ActiveRecord Calss 呼叫的,但 block 裡面不一定都要是那個 Class 的 instance
你可以混著不同的 model 在同一個 transaction block, This is because transactions are per-database connection, not per-model.
Client.transaction do # 或 @client.transaction do
@client.users.create!
@user.clients.first.destroy!
Product.first.destroy!
end
注意!! 要使用 The bang modifier (!) - save!
而不是 save
, 原因是 transaction reset 是經由 rollback 觸發後才重置的,
然而 rollback 只會被 Exception 觸發,所以當你執行如 update_attribute
等等的 method 時,當它失敗它會擲出 false,
但它並不會觸發 rollback,所以一定要加上 !
,讓它發生錯誤時擲出 Exception
基於上述的觀念也可以得出另個結論 - 如果有 exception 表示有 rollback,沒有的話代表 transaction 是成功的,可以進一步針對失敗時要做的處理
def test
ActiveRecord::Base.transaction do
# do something ...
end
rescue => e
render json: {updated: false}
end
Transactions are not distributed across database connections
一個 transaction 只會作用在單一的 database connection. 如果你有多個指定用途的 databases,那麼你應該使用 nested transaction 來解決
Client.transaction do
Product.transaction do
product.buy(@quantity)
client.update_attributes!(:sales_count => @sales_count + 1)
end
end
一般情況都是規劃一個 database 運行網站,這樣則不需要用到 nested transaction
Lock row - .lock!
這個 example 先將某筆 lock 鎖起來,並且 sleep 後十秒,在這期間內去 update 它觀察它的變化
為了方便直接用 Rails console 測 (要開兩個 console 才能測)
第一個 console
Case.transaction do; @case = Case.find(112); @case.lock!; sleep(10); end
# Case.transaction do
# @case = Case.find(112)
# @case.lock!
# sleep(10)
# end
第二個 console
Case.find(112).update(message: 'VVV')
結果你會看到 : 一筆資料被 lock 了十秒鐘, 如果其他地方 update 這筆資料的話會等在那邊,
直到解除 (如果是更新同個 TABLE 不同筆資料則不會被 lock)
Lock row - .with_lock
作用與 lock!
一樣,不同的是它是個 block :
account = Account.first
account.with_lock do
# This block is called within a transaction,
# account is already locked.
account.balance -= 100
account.save!
end
ref : http://markdaggett.com/blog/2011/12/01/transactions-in-rails/
Lock row - .lock.find(id)
user = User.lock.find(1)
# do something
user.save!
Permalink
Permalink 原生 to_param
讓你的 URL 更 Friendly (i.e. /article/environment-protection
), 使用原生就有支援
def to_param
"#{id} #{name}".parameterize
end
id, 與 name 是欄位名稱, 當 url 產生如 : 3-peter
但中文是不行的, 需要用其他套件處理 i.e. Babosa
它能將原本的 auto increament 改成使用中文或英文(i.e. environment-protection-is-important
) 這種標題, 功能較強大
Install
gem 'friendly_id', '~> 5.1.0'
[1] Migrate
add_column :products, :slug, :string
add_index :products, :slug, unique: true
[2] 在 Product Model 加上
extend FriendlyID
friendly_id :name, use: :slugged
當建立資料後,欄位 slug
的值就會是 product 的 name
但原本已經存在的資料沒有 slug 值,我們也要更新它 :
Product.find_each(&:save)
[3] 設定 friendly.find 自動/手動 2選1
- 自動 - 讓 find 自動找到 friendly_id
目前你應該可以看到 product 的 path 都是產品名稱了,但你可能會發現連結都是壞的,因為 find 還沒有自動對應,我們要來設定它
rails generate friendly_id --skip-migration # 產生 config/initializers/friendly_id.rb
config/initializers/friendly_id.rb
config.use :finders # 將原本註解取消
如果你用 .find(params[:id])
它就會自動用 params[:id]
去找 :slug
欄位
-
手動 - 你不想設定自動對應你也可以每次都寫
Product.friendly.find(params[:id])
[4] 完成
Done!
改善 URL 不要顯示 ID Auto-Increament
有時我們不希望我們的 id 曝露在外,如何將 order/3
改為 order/20160415-ORDER-B9DC88E23A4CE439C13A
-
Migration - 首先我們先加上 uuid 欄位, 把它當作另一個主 Key
t.string :uuid, limit: ‘100’
add_index :orders, [:uuid], unique: true
-
Model - create 前亂數產生 uuid
before_create :generate_uuid
def generate_uuid
self.uuid = “#{Time.now.strftime(’%Y%m%d’)}-ORDER-#{SecureRandom.hex(10).upcase}”
end
-
Model - 設定 param, 讓 path 顯示 uuid (讓 uuid 代替 id)
def to_param
self.uuid
end
-
View - 不需要特別改,就會顯示 uuid 而不是 id
edit_product_path(p)
-
Controller - find by uuid
@order = current_user.orders.find_by_uuid(params[:id])
Paranoia 軟刪除 soft-deleted
軟刪除意思是有些資料需要刪除不顯示在頁面上,但又想保留資料,因為不知道未來哪天會需要到;一般作法是透過 deleted_at 欄位來判斷是否已刪除
雖然實作軟刪除很簡單,但如果很多 model 都要寫的話,就會挺煩人的,這是一個軟刪除的套件,幫你這些重覆的事情做掉
Gemfile 安裝
gem "paranoia", "~> 2.1.5"
安裝完要記得重啟 rails
對想要軟體除的 Model 及 Table 建立必備資料
migration
class AddDeletedAtToClients < ActiveRecord::Migration
def change
add_column :clients, :deleted_at, :datetime
add_index :clients, :deleted_at
end
end
model
class Client < ActiveRecord::Base
acts_as_paranoid
end
基本操作
軟刪除
Client.find(5).destroy
client.destroy
軟刪除後,Client.all
及 Client.find(5)
都會撈不到,必須用 Client.only_deleted
才剛的到
恢復被軟刪除的資料
Client.restore(5)
Client.only_deleted.find(5).restore
顯示所有資料(包含已刪除的資料)
Client.with_deleted
顯示被刪除的資料
Client.only_deleted
真實刪除(要注意, 是真的刪掉!)
client.really_destroy!
Console 下 Reload model
如果在 model 改的情況下, console 會一直是吃舊的 model 設定, 除非重新啟動 console, 或直接下 reload
> reload!
Reloading...
=> true
clone 一筆 record
a = User.find(103).dup # 會自動把 id 為 nil
User.create(a.dup.attributes)
或
a = User.find(103)
User.create(a.attributes.except("id"))
其他
i18n
zh-TW:
activerecord:
attributes:
user:
name: '姓名'
姓名 必填
model 裡使用 asset helper
# dev production 都 work
default_url: ->(attachment) { ActionController::Base.helpers.image_path('icons/avatar.png') },
# 後來發現在 dev work, 但 production 不 work
default_url: ActionController::Base.helpers.image_path('default/avatar-150x150.png')
顯示 SQL
User.where(:id => 3).to_sql
concerns - model 之間共用 method
controllers/concerns 與 models/concerns 是共通的
/models/concerns/example.rb
module Example
def self.location
{
/* something else */
}
end
end
/models/user.rb
enum location: Example.location