Software engineering notes

Rails Model

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 行為

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')

Enum + i18n + Radio button

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!

讓你的 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

  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 欄位

  1. 手動 - 你不想設定自動對應你也可以每次都寫

    Product.friendly.find(params[:id])

[4] 完成

Done!

改善 URL 不要顯示 ID Auto-Increament

有時我們不希望我們的 id 曝露在外,如何將 order/3 改為 order/20160415-ORDER-B9DC88E23A4CE439C13A

  1. Migration - 首先我們先加上 uuid 欄位, 把它當作另一個主 Key

    t.string :uuid, limit: ‘100’

    add_index :orders, [:uuid], unique: true

  2. Model - create 前亂數產生 uuid

    before_create :generate_uuid

    def generate_uuid self.uuid = “#{Time.now.strftime(’%Y%m%d’)}-ORDER-#{SecureRandom.hex(10).upcase}” end

  3. Model - 設定 param, 讓 path 顯示 uuid (讓 uuid 代替 id)

    def to_param self.uuid end

  4. View - 不需要特別改,就會顯示 uuid 而不是 id

    edit_product_path(p)

  5. 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.allClient.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