更多內容 rubyonrails.org: 更多內容

Active Record 查詢

本篇詳細介紹各種用 Active Record 多種從資料庫取出資料的方法。

讀完本篇,您將了解:

如果習慣寫純 SQL 來查詢資料庫,則會發現在 Rails 裡有更好的方式可以執行同樣的操作。Active Record 適用於大多數場景,需要寫 SQL 的場景會變得非常少。

本篇之後的例子都會用下列的 Model 來講解:

除非特別說明,否則下列 Model 都用 id 作為主鍵。

class Client < ActiveRecord::Base
  has_one  :address
  has_many :orders
  has_and_belongs_to_many :roles
end

class Address < ActiveRecord::Base
  belongs_to :client
end

class Order < ActiveRecord::Base
  belongs_to :client, counter_cache: true
end

class Role < ActiveRecord::Base
  has_and_belongs_to_many :clients
end

Active Record 幫你對資料庫做查詢,相容多數資料庫(MySQL、PostgreSQL 以及 SQLite 等)。不管用的是何種資料庫,Active Record 方法格式保持一致。

1 取出資料

Active Record 提供了多種 Finder 方法,用來從資料庫裡取出物件。每個 Finder 方法允許傳參數,來對資料庫執行不同的查詢,而無需直接寫純 SQL。

Finder 方法有:

  • bind
  • create_with
  • distinct
  • eager_load
  • extending
  • from
  • group
  • having
  • includes
  • joins
  • limit
  • lock
  • none
  • offset
  • order
  • preload
  • readonly
  • references
  • reorder
  • reverse_order
  • select
  • uniq
  • where

以上方法皆會回傳一個 ActiveRecord::Relation 實體。

Model.find(options) 的主要操作可以總結如下:

  • 將傳入的參數轉換成對應的 SQL 語句。
  • 執行 SQL 語句,去資料庫取回對應的結果。
  • 將每個查詢結果,根據適當的 Model 實體化出 Ruby 物件。
  • after_find 回呼的話,執行它們。

1.1 取出單一物件

Active Record 提供數種方式來取出一個物件。

1.1.1 find

使用 find 來取出給定主鍵(primary key)的物件,比如:

# Find the client with primary key (id) 10.
client = Client.find(10)
# => #<Client id: 10, first_name: "Ryan">

對應的 SQL:

SELECT * FROM clients WHERE (clients.id = 10) LIMIT 1

如果 find 沒找到符合條件的記錄,則會拋出 ActiveRecord::RecordNotFound 異常。

也可以用來查詢多個物件:傳給 find 一個主鍵陣列即可。會回傳陣列所有提供的主鍵所找到的紀錄,譬如:

# Find the clients with primary keys 1 and 10.
client = Client.find([1, 10]) # Or even Client.find(1, 10)
# => [#<Client id: 1, first_name: "Lifo">, #<Client id: 10, first_name: "Ryan">]

上例等效的 SQL:

SELECT * FROM clients WHERE (clients.id IN (1,10))

若不是所有提供的主鍵都有找到匹配的物件,則 find 方法會拋出 ActiveRecord::RecordNotFound 異常。

1.1.2 take

take 方法取出 limit 筆記錄,不特別排序,比如:

client = Client.take
# => #<Client id: 1, first_name: "Lifo">

對應的 SQL:

SELECT * FROM clients LIMIT 1

若沒找到記錄會拋出異常,take 則回傳 nil

可以傳一個數值參數給 take,會回傳多筆結果。比如:

client = Client.take(2)
# => [
  #<Client id: 1, first_name: "Lifo">,
  #<Client id: 220, first_name: "Sara">
]

對應的 SQL:

SELECT * FROM clients LIMIT 2

take! 的行為同 take,但在沒找到記錄時會拋出 ActiveRecord::RecordNotFound

取出記錄的結果可能隨資料庫引擎的不同而變化。

1.1.3 first

first 按主鍵排序,取出第一筆資料,比如:

client = Client.first
# => #<Client id: 1, first_name: "Lifo">

對應的 SQL:

SELECT * FROM clients LIMIT 1

如果沒找到記錄,first 會回傳 nil,不會拋出異常。

first! 的行為同 first,但在沒找到記錄時會拋出 ActiveRecord::RecordNotFound 異常。

1.1.4 last

last 按主鍵排序,取出最後一筆資料,比如:

client = Client.last
# => #<Client id: 221, first_name: "Russel">

對應的 SQL:

SELECT * FROM clients ORDER BY clients.id DESC LIMIT 1

如果沒找到記錄,last 會回傳 nil,不會拋出異常。

可傳數值參數給 last,會回傳最後幾筆結果,譬如:

client = Client.last(3)
# => [
  #<Client id: 219, first_name: "James">,
  #<Client id: 220, first_name: "Sara">,
  #<Client id: 221, first_name: "Russel">
]

上例對應 SQL:

SELECT * FROM clients ORDER BY clients.id DESC LIMIT 3

last! 行為同 last,但在沒找到記錄時會拋出 ActiveRecord::RecordNotFound 異常。

1.1.5 find_by

find_by 找出第一筆符合條件的記錄,譬如:

Client.find_by first_name: 'Lifo'
# => #<Client id: 1, first_name: "Lifo">

Client.find_by first_name: 'Jon'
# => nil

等同於:

Client.where(first_name: 'Lifo').take

find_by! 行為同 find_by,只是在沒找到符合條件的紀錄時會拋出 ActiveRecord::RecordNotFound,譬如:

Client.find_by! first_name: 'does not exist'
# => ActiveRecord::RecordNotFound

等同於:

Client.where(first_name: 'does not exist').take!

1.1.6 first

first 取出 limit 筆記錄,按主鍵排序:

client = Client.first
# => #<Client id: 1, first_name: "Lifo">

對應的 SQL:

SELECT * FROM clients ORDER BY id ASC LIMIT 2

1.1.7 last

Model.last(limit) 按主鍵排序,從後取出 limit 筆記錄:

Client.last(2)
# => [#<Client id: 10, first_name: "Ryan">,
      #<Client id: 9, first_name: "John">]

對應的 SQL:

SELECT * FROM clients ORDER BY id DESC LIMIT 2

1.2 批次取出多筆記錄

處理多筆記錄是常見的需求,比如寄信給使用者,轉出資料。

直覺可能會這麼做:

# 如果有數千個使用者,效率非常差。
User.all.each do |user|
  NewsMailer.weekly(user).deliver_now
end

但在資料表很大的時候,這個方法便不實用了。由於 User.all.each 告訴 Active Record 一次去把整張表抓出來,再為表的每一列建出物件,最後將所有的物件放到記憶體裡。如果資料庫裡存了非常多筆記錄,可能會把記憶體用光。

Rails 提供了兩個方法來解決這個問題,將記錄針對記憶體來說有效率的大小,分批處理。第一個方法是 find_each,取出一批記錄,並將每筆記錄傳入至區塊裡,可取單一筆記錄。第二個方法是 find_in_batches,一次取一批記錄,整批放至區塊裡,整批記錄以陣列形式取用。

find_eachfind_in_batches 方法專門用來解決大量記錄,處理無法一次放至記憶體的大量記錄。如果只是一千筆資料,使用平常的查詢方法便足夠了。

1.2.1 find_each

find_each 方法取出一批記錄,將每筆記錄傳入區塊裡。下面的例子,將以 find_each 來取出 1000 筆記錄(find_eachfind_in_batches 的預設值),並傳至區塊。一次處理 1000 筆,直至記錄通通處理完畢為止:

User.find_each do |user|
  NewsMailer.weekly(user).deliver_now
end

要給 find_each 加上條件,可以像用 where 一樣連鎖使用:

User.where(weekly_subscriber: true).find_each do |user|
  NewsMailer.weekly(user).deliver_now
end

1.2.1.1 find_each 選項

find_each 方法接受多數 find 所允許的選項,除了 :order:limit,這兩個選項保留供 find_each 內部使用。

此外有兩個額外的選項,:batch_size:start

:batch_size

:batch_size 選項允許你在將各筆記錄傳進區塊前,指定一批要取多少筆記錄。比如一次取 5000 筆:

User.find_each(batch_size: 5000) do |user|
  NewsMailer.weekly(user).deliver_now
end

:start

預設記錄按主鍵升序取出,主鍵類型必須是整數。批次預設從最小的 ID 開始,可用 :start 選項可以設定批次的起始 ID。在前次被中斷的批量處理重新開始的場景下很有用。

舉例來說,本週總共有 5000 封信要發。1-1999 已經發過了,便可以使用此選項從 2000 開始發信:

User.find_each(start: 2000, batch_size: 5000) do |user|
  NewsMailer.weekly(user).deliver_now
end

另個例子是想要多個 worker 處理同個佇列時。可以使用 :start 讓每個 worker 分別處理 10000 筆記錄。

1.2.2 find_in_batches

find_in_batches 方法與 find_each 類似,皆用來取出記錄。差別在於 find_in_batchs 取出記錄放入陣列傳至區塊,而 find_each 是一筆一筆放入區塊。下例會一次將 1000 張發票拿到區塊裡處理:

# Give add_invoices an array of 1000 invoices at a time
Invoice.find_in_batches do |invoices|
  export.add_invoices(invoices)
end

1.2.2.1 find_in_batches 接受的選項

find_in_batches 方法接受和 find_each 一樣的選項: :batch_size:start

2 條件

where 方法允許取出符合條件的記錄,where 即代表了 SQL 語句的 WHERE 部分。

條件可以是字串、陣列、或是 Hash。

2.1 字串條件

直接將要使用的條件,以字串形式傳入 where 即可。如 Client.where("orders_count = '2'") 會回傳所有 orders_count 是 2 的 clients。

條件是純字串可能有 SQL injection 的風險。舉例來說,Client.where("first_name LIKE '%#{params[:first_name]}%'") 是不安全的,參考下節如何將字串條件改用陣列來處理。

2.2 陣列條件

如果我們要找的 orders_count,不一定固定是 2,可能是不定的數字:

Client.where("orders_count = ?", params[:orders])

Active Record 會將 ? 換成 params[:orders] 做查詢。也可宣告多個條件,條件式後的元素,對應到條件裡的每個 ?

Client.where("orders_count = ? AND locked = ?", params[:orders], false)

上例第一個 ? 會換成 params[:orders],第二個則會換成 SQL 裡的 false (根據不同的 adapter 而異)。

這麼寫

Client.where("orders_count = ?", params[:orders])

比下面這種寫法好多了

Client.where("orders_count = #{params[:orders]}")

因為前者比較安全。直接將變數插入條件字串裡,不論變數是什麼,都會直接存到資料庫裡。這表示從惡意使用者傳來的變數,會直接存到資料庫。這麼做是把資料庫放在風險裡不管啊!一旦有人知道,可以隨意將任何字串插入資料庫裡,就可以做任何想做的事。絕對不要直接將變數插入條件字串裡。

關於更多 SQL injection 的資料,請參考 Ruby on Rails 安全指南

2.2.1 佔位符

替換除了可以使用 ? 之外,用符號也可以。以 Hash 的鍵值對方式,傳入陣列條件:

Client.where("created_at >= :start_date AND created_at <= :end_date", {start_date: params[:start_date], end_date: params[:end_date]})

若條件中有許多參數,這種寫法不僅提高了可讀性,傳遞起來也更方便。

2.3 Hash

Active Record 同時允許你傳入 Hash 形式的條件,以提高條件式的可讀性。使用 Hash 條件時,鍵是要查詢的欄位、值為期望值。

只有 Equality、Range、subset 可用這種形式來寫條件。

2.3.1 Equality
Client.where(locked: true)

欄位名稱也可以是字串:

Client.where('locked' => true)

belongs_to 關係裡,關聯名稱也可以用來做查詢,polymorphic 關係也可以。

Address.where(client: client)
Address.joins(:clients).where(clients: {address: address})

Note: 條件的值不能用符號。比如這樣是不允許的 Client.where(status: :active)

2.3.2 Range
Client.where(created_at: (Time.now.midnight - 1.day)..Time.now.midnight)

會使用 SQL 的 BETWEEN 找出所有在昨天建立的客戶。

SELECT * FROM clients WHERE (clients.created_at BETWEEN '2008-12-21 00:00:00' AND '2008-12-22 00:00:00')

這種寫法展示了如何簡化陣列條件

2.3.3 Subset

如果要使用 SQL 的 IN 來查詢,可以在條件 Hash 裡傳入陣列:

Client.where(orders_count: [1,3,5])

上例會產生像是如下的 SQL:

SELECT * FROM clients WHERE (clients.orders_count IN (1,3,5))

2.4 NOT

SQL 的 NOT 可以使用 where.not

Article.where.not(author: author)

換句話說,先不傳參數呼叫 where,再使用 not 傳入 where 條件。

3 排序

要按照特定順序來取出記錄,可以使用 order 方法。

比如有一組記錄,想要按照 created_at 升序排列:

Client.order(:created_at)
# OR
Client.order("created_at")

升序 ASC;降序 DESC

Client.order(created_at: :desc)
# OR
Client.order(created_at: :asc)
# OR
Client.order("created_at DESC")
# OR
Client.order("created_at ASC")

排序多個欄位:

Client.order(orders_count: :asc, created_at: :desc)
# OR
Client.order(:orders_count, created_at: :desc)
# OR
Client.order("orders_count ASC, created_at DESC")
# OR
Client.order("orders_count ASC", "created_at DESC")

如果想在不同的語境裡連鎖使用 order,SQL 的 ORDER BY 順序與呼叫順序相同:

Client.order("orders_count ASC").order("created_at DESC")
# SELECT * FROM clients ORDER BY orders_count ASC, created_at DESC

4 選出特定欄位

Model.find 預設會使用 select * 取出所有的欄位。

只要取某些欄位的話,可以透過 select 方法來宣告。

比如,只要 viewable_bylocked 欄位:

Client.select("viewable_by, locked")

會產生出像是下面的 SQL 語句:

SELECT viewable_by, locked FROM clients

要小心使用 select。因為實體化出來的物件僅有所選欄位。如果試圖存取不存在的欄位,會得到 ActiveModel::MissingAttributeError 異常:

ActiveModel::MissingAttributeError: missing attribute: <attribute>

上面的 <attribute> 會是試圖存取的欄位。id 方法不會拋出 ActiveModel::MissingAttributeError,所以在關聯裡使用要格外注意,因為關聯要有 id 才能正常工作。

如果想找出特定欄位所有不同的數值,使用 distinct

Client.select(:name).distinct

會產生如下 SQL:

SELECT DISTINCT name FROM clients

也可以之後移掉唯一性的限制:

query = Client.select(:name).distinct
# => Returns unique names

query.distinct(false)
# => Returns all names, even if there are duplicates

5 Limit 與 Offset

要在 Model.find 裡使用 SQL 的 LIMIT,可以對 Active Record Relation 使用 limitoffset 方法 可以指定從第幾個記錄開始查詢。比如:

Client.limit(5)

最多會回傳 5 位客戶。因為沒指定 offset,會回傳資料比如的前 5 筆。產生的 SQL 會像是:

SELECT * FROM clients LIMIT 5

上例加上 offset

Client.limit(5).offset(30)

會從資料庫裡的第 31 筆開始,最多回傳 5 位客戶的紀錄,產生的 SQL 像是:

SELECT * FROM clients LIMIT 5 OFFSET 30

6 Group

要在 Model.find 裡使用 SQL 的 LIMIT,可以對 Active Record Relation 使用 group 方法。

比如想找出某日的訂單:

Order.select("date(created_at) as ordered_date, sum(price) as total_price").group("date(created_at)")

會依照存在資料庫裡的順序,按日期回傳單筆訂單物件。

產生的 SQL 會像是:

SELECT date(created_at) as ordered_date, sum(price) as total_price
FROM orders
GROUP BY date(created_at)

6.1 分組項目的總數

要取得單一查詢呼叫有幾筆結果,在 group 之後呼叫 count

Order.group(:status).count
# => { 'awaiting_approval' => 7, 'paid' => 12 }

上例執行的 SQL 看起來會像是:

SELECT COUNT (*) AS count_all, status AS status
FROM "orders"
GROUP BY status

7 Having

在 SQL 裡,可以使用 HAVING 子句來對 GROUP BY 欄位下條件。Model.find 加入 :having 選項。

比如:

Order.select("date(created_at) as ordered_date, sum(price) as total_price").
  group("date(created_at)").having("sum(price) > ?", 100)

產生的 SQL 會像是:

SELECT date(created_at) as ordered_date, sum(price) as total_price
FROM orders
GROUP BY date(created_at)
HAVING sum(price) > 100

這會回傳每天總價大於 100 的訂單。

8 覆蓋條件

8.1 unscope

可以使用 unscope 來指定要移除的特定條件,譬如:

Article.where('id > 10').limit(20).order('id asc').unscope(:order)

執行的 SQL 可能是:

SELECT * FROM articles WHERE id > 10 LIMIT 20

# Original query without `unscope`
SELECT * FROM articles WHERE id > 10 ORDER BY id asc LIMIT 20

也可以 unscope 特定的 where 子句,譬如:

Article.where(id: 10, trashed: false).unscope(where: :id)
# SELECT "articles".* FROM "articles" WHERE trashed = 0

使用了 unscope 的 Relation 會影響與其合併的 Relation:

Article.order('id asc').merge(Article.unscope(:order))
# SELECT "articles".* FROM "articles"

8.2 only

only 可以留下特定條件,比如:

Article.where('id > 10').limit(20).order('id desc').only(:order, :where)

執行的 SQL 語句:

SELECT * FROM articles WHERE id > 10 ORDER BY id DESC

# Original query without `only`
SELECT "articles".* FROM "articles" WHERE (id > 10) ORDER BY id desc LIMIT 20

8.3 reorder

reorder 可以覆蓋掉預設 scope 的 order 條件:

class Article < ActiveRecord::Base
  has_many :comments, -> { order('posted_at DESC') }
end

Article.find(10).comments.reorder('name')

執行的 SQL 語句:

SELECT * FROM articles WHERE id = 10
SELECT * FROM comments WHERE article_id = 10 ORDER BY name

原本會執行的 SQL 語句(沒用 reorder):

SELECT * FROM articles WHERE id = 10
SELECT * FROM comments WHERE article_id = 10 ORDER BY posted_at DESC

8.4 reverse_order

reverse_order 方法反轉 order 條件。

Client.where("orders_count > 10").order(:name).reverse_order

執行的 SQL 語句(ASC 反轉為 DESC):

SELECT * FROM clients WHERE orders_count > 10 ORDER BY name DESC

如果查詢裡沒有 order 條件,預設 reverse_order 會對主鍵做反轉。

Client.where("orders_count > 10").reverse_order

執行的 SQL 語句:

SELECT * FROM clients WHERE orders_count > 10 ORDER BY clients.id DESC

reverse_order 不接受參數

9 空 Relation

none 方法回傳一個不包含任何記錄、可連鎖使用的 Relation。none 回傳的 Relation 上做查詢,仍會回傳空的 Relation。應用場景是回傳的 Relation 可能沒有記錄,但需要可以連鎖使用。

Article.none # returns an empty Relation and fires no queries.

回傳空的 Relation,不會對資料庫下查詢。

# The visible_articles method below is expected to return a Relation.
@articles = current_user.visible_articles.where(name: params[:name])

def visible_articles
  case role
  when 'Country Manager'
    Article.where(country: country)
  when 'Reviewer'
    Article.published
  when 'Bad User'
    Article.none # => returning [] or nil breaks the caller code in this case
  end
end

上例 visible_articles 可能沒有可見的 articles,但之後還有 where 子句,此時沒有 articles 的情況可以使用 Article.none

10 唯讀物件

Active Record 提供 readonly 方法,用來禁止修改回傳的物件。試圖要修改 readonly 物件徒勞無功,並會拋出 ActiveRecord::ReadOnlyRecord 異常。

client = Client.readonly.first
client.visits += 1
client.save

client 明確設定為唯讀物件,上面的程式碼在執行到 client.save 時會拋出 ActiveRecord::ReadOnlyRecord 異常,因為 visits 的數值改變了。

11 更新時鎖定記錄

鎖定可以避免更新可能發生的 race condition,確保更新是原子性的操作。

Active Record 提供兩種鎖定機制:

  • 樂觀鎖定(Optimistic Locking)
  • 悲觀鎖定(Pessimistic Locking)

11.1 樂觀鎖定

樂觀鎖定允許多個使用者編輯相同的紀錄,並假設資料衝突發生衝突的可能性最小。透過檢查該記錄從資料庫取出後,是否有另個進程修改此記錄。如果有其他進程同時修改記錄時,會拋出 ActiveRecord::StaleObjectError 異常。

樂觀鎖定欄位

要使用樂觀鎖定,資料表需要加一個叫做 lock_version 的整數欄位。記錄更新時,Active Record 會遞增 lock_version。如果正在更新的記錄的 lock_version 比資料庫裡的 lock_version 值小時,會拋出 ActiveRecord::StaleObjectError,比如:

c1 = Client.find(1)
c2 = Client.find(1)

c1.first_name = "Michael"
c1.save

c2.name = "should fail"
c2.save # Raises an ActiveRecord::StaleObjectError

拋出異常後您要負責處理,將異常救回來。看是要回滾、合併或是根據商業邏輯來處理衝突。

這個行為可以透過設定 ActiveRecord::Base.lock_optimistically = false 來關掉。

lock_version 欄位名可以透過 ActiveRecord::Base 提供的類別屬性 locking_column 來覆蓋:

class Client < ActiveRecord::Base
  self.locking_column = :lock_client_column
end

11.2 悲觀鎖定

悲觀鎖定使用資料庫提供的鎖定機制。在建立 Relation 時,使用 lock 可以對選擇的列獲得一個互斥鎖。通常使用 lock 的 Relation 會包在 transaction 裡,避免死鎖的情況發生。

比如:

Item.transaction do
  i = Item.lock.first
  i.name = 'Jones'
  i.save
end

上面的程式碼在 MySQL 會產生如下 SQL:

SQL (0.2ms)   BEGIN
Item Load (0.3ms)   SELECT * FROM `items` LIMIT 1 FOR UPDATE
Item Update (0.4ms)   UPDATE `items` SET `updated_at` = '2009-02-07 18:05:56', `name` = 'Jones' WHERE `id` = 1
SQL (0.8ms)   COMMIT

lock 方法可以傳純 SQL,來使用不同種類的鎖。比如 MySQL 有 LOCK IN SHARE MODE,鎖定記錄同時允許查詢讀取。直接傳入 lock 即可使用:

Item.transaction do
  i = Item.lock("LOCK IN SHARE MODE").find(1)
  i.increment!(:views)
end

如果已經有 Model 的實體,使用以下寫法,可以將操作包在 transaction 裡,並同時獲得鎖:

item = Item.first
item.with_lock do
  # This block is called within a transaction,
  # item is already locked.
  item.increment!(:views)
end

12 連接資料表

Active Record 提供一個 Finder 方法,joins。用來對 SQL 指定 JOIN 子句。joins 有多種使用方式。

12.1 使用字串形式的 SQL 片段

joins 裡寫純 SQL 來指定 JOIN

Client.joins('LEFT OUTER JOIN addresses ON addresses.client_id = clients.id')

會產生下面的 SQL:

SELECT clients.* FROM clients LEFT OUTER JOIN addresses ON addresses.client_id = clients.id

12.2 使用關聯名稱的陣列或 Hash 形式

此法僅對 INNER JOIN 有效。

Active Record 允許在使用 joins 方法時,使用關聯名稱來指定 JOIN 子句。

舉個例子,以下有 CategoryArticleCommentGuest 以及 Tag Models:

class Category < ActiveRecord::Base
  has_many :articles
end

class Article < ActiveRecord::Base
  belongs_to :category
  has_many :comments
  has_many :tags
end

class Comment < ActiveRecord::Base
  belongs_to :article
  has_one :guest
end

class Guest < ActiveRecord::Base
  belongs_to :comment
end

class Tag < ActiveRecord::Base
  belongs_to :article
end

接下來,以下的方法都會使用 INNER JOIN 來產生出連接查詢(join queries):

12.2.1 連接單個關聯
Category.joins(:articles)

會產生:

SELECT categories.* FROM categories
  INNER JOIN articles ON articles.category_id = categories.id

用白話解釋是:“依文章分類來回傳分類物件”。注意到如果有 article 是相同類別,會看到重複的分類物件。若要去掉重複結果,可以使用 Category.joins(:articles).uniq

12.2.2 連接多個關聯
Article.joins(:category, :comments)

會產生:

SELECT articles.* FROM articles
  INNER JOIN categories ON articles.category_id = categories.id
  INNER JOIN comments ON comments.article_id = articles.id

用白話解釋是:“依分類來回傳文章物件,且文章至少有一則評論”。有多則評論的文章將會出現很多次。

12.2.3 連接一層嵌套關聯
Article.joins(comments: :guest)

會產生:

SELECT articles.* FROM articles
  INNER JOIN comments ON comments.article_id = articles.id
  INNER JOIN guests ON guests.comment_id = comments.id

用白話解釋是:“回傳所有有訪客評論的文章”。

12.2.4 連接多層嵌套關聯
Category.joins(articles: [{comments: :guest}, :tags])

會產生:

SELECT categories.* FROM categories
  INNER JOIN articles ON articles.category_id = categories.id
  INNER JOIN comments ON comments.article_id = articles.id
  INNER JOIN guests ON guests.comment_id = comments.id
  INNER JOIN tags ON tags.article_id = articles.id

12.3 對連接的資料表指定條件

可以對連接的資料表使用一般的陣列字串條件。[Hash]條件則是有提供特殊的語法來下條件:

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Client.joins(:orders).where('orders.created_at' => time_range)

另一種更簡潔的寫法是使用嵌套 Hash:

time_range = (Time.now.midnight - 1.day)..Time.now.midnight
Client.joins(:orders).where(orders: {created_at: time_range})

會用 BETWEEN 找到所有昨天下訂單的客戶。

13 Eager Loading 關聯

Eager loading 是載入由 Model.find 回傳的物件關聯記錄的機制,將查詢數降到最低。

N + 1 查詢問題

考慮以下程式碼。找出 10 位客戶,印出他們的郵遞區號:

clients = Client.limit(10)

clients.each do |client|
  puts client.address.postcode
end

程式碼一眼看起來沒什麼問題。但問題是,總共執行了幾次查詢?上例程式碼總共會執行 11 次查詢,1 次用來取得 10 位客戶、10 次用來取得客戶地址的郵遞區號。

N + 1 查詢的解法

Active Record 透過使用 Model.find 搭配 includes 方法,可預先指定所有會載入的關聯。有了 includes,Active Record 確保所有指定的關聯,加載的查詢減到最少。

用 Eager Loading 重寫上例:

clients = Client.includes(:address).limit(10)

clients.each do |client|
  puts client.address.postcode
end

上面的程式碼只會執行 2 次查詢。

SELECT * FROM clients LIMIT 10
SELECT addresses.* FROM addresses
  WHERE (addresses.client_id IN (1,2,3,4,5,6,7,8,9,10))

13.1 Eager Loading 多個關聯

使用 Model.findincludes 方法,Active Record 可以 Eager Load 任意數量的關聯。關聯可以以陣列、Hash 或是嵌套 Hash(內有陣列、Hash)形式指定。

13.1.1 陣列有多個關聯
Article.includes(:category, :comments)

會加載所有文章,以及每篇文章的類別與評論。

13.1.2 嵌套關聯 Hash
Category.includes(articles: [{ comments: :guest }, :tags]).find(1)

會找到 category id 為 1 的類別,並加載與類別相關聯的文章。以及文章的標籤與評論跟評論的 guest 關聯。

13.2 對 Eager Loaded 關聯下條件

雖然 Active Record 允許您像 joins 那樣對 eager loaded 關聯下條件,但推薦的做法是使用連接資料表

但若非要這麼做,可以像平常那樣使用 where

Article.includes(:comments).where(comments: { visible: true })

產生的查詢語句會有 LEFT OUTER JOIN,而 joins 產生的是 INNER JOIN

SELECT "articles"."id" AS t0_r0, ... "comments"."updated_at" AS t1_r5 FROM "articles" LEFT OUTER JOIN "comments" ON "comments"."article_id" = "articles"."id" WHERE (comments.visible = 1)

如果沒有下 where 條件,則會像平常那樣產生兩條查詢。

where 的這種用法只對參數是 Hash 有效。傳入參數是 SQL 片段,要使用 references 來強制連接資料表。

Article.includes(:comments).where("comments.visible = true").references(:comments)

上例若文章都沒有評論,仍會載入所有文章。然而使用 joinsINNER JOIN必須要滿足連接條件,不然不會回傳任何記錄。

14 作用域

作用域(Scopes)允許將常用查詢定義成關聯物件或 Model 的方法。作用域可以使用前面介紹過的 wherejoinsincludes 等方法。所有作用域方法會回傳一個 ActiveRecord::Relation 物件,允許之後的方法(像是作用域)來繼續呼叫。

要定義一個簡單的作用域,在類別裡使用 scope 方法,傳入呼叫此作用域時想執行的查詢即可:

class Article < ActiveRecord::Base
  scope :published, -> { where(published: true) }
end

這與定義一個類別方法完全相同,用那個完全是個人喜好:

class Article < ActiveRecord::Base
  def self.published
    where(published: true)
  end
end

作用域可以與其它作用域連鎖使用:

class Article < ActiveRecord::Base
  scope :published,               -> { where(published: true) }
  scope :published_and_commented, -> { published.where("comments_count > 0") }
end

要呼叫 published 作用域,可以在類上呼叫:

Article.published # => [published articles]

或是對由 Article 物件組成的關聯使用:

category = Category.first
category.articles.published # => [published articles belonging to this category]

14.1 傳入參數

作用域可接受參數:

class Article < ActiveRecord::Base
  scope :created_before, ->(time) { where("created_at < ?", time) }
end

像呼叫類別方法那般使用作用域

Article.created_before(Time.zone.now)

這只是重複類別方法可提供的功能。

class Article < ActiveRecord::Base
  def self.created_before(time)
    where("created_at < ?", time)
  end
end

作用域需要接受參數偏好使用類別方法。接受參數的類別方法仍可在關聯物件上使用:

category.articles.created_before(time)

14.2 合併作用域

where 條件類似,作用域使用 SQL 的 AND 來合併。

class User < ActiveRecord::Base
  scope :active, -> { where state: 'active' }
  scope :inactive, -> { where state: 'inactive' }
end

User.active.inactive
# => SELECT "users".* FROM "users" WHERE "users"."state" = 'active' AND "users"."state" = 'inactive'

scope 作用域與 where 條件可以混用,最終的 SQL 會用 AND 把所有條件連結起來。

User.active.where(state: 'finished')
# => SELECT "users".* FROM "users" WHERE "users"."state" = 'active' AND "users"."state" = 'finished'

如果想讓最後一個 where 條件覆蓋先前的,可以使用 Relation#merge

User.active.merge(User.inactive)
# => SELECT "users".* FROM "users" WHERE "users"."state" = 'inactive'

一個重要的提醒是 default_scope 會被 scope 作用域與 where 條件覆蓋掉。

class User < ActiveRecord::Base
  default_scope { where state: 'pending' }
  scope :active, -> { where state: 'active' }
  scope :inactive, -> { where state: 'inactive' }
end

User.all
# => SELECT "users".* FROM "users" WHERE "users"."state" = 'pending'

User.active
# => SELECT "users".* FROM "users" WHERE "users"."state" = 'active'

User.where(state: 'inactive')
# => SELECT "users".* FROM "users" WHERE "users"."state" = 'inactive'

如上所見,default_scopescopewhere 覆蓋掉了。

14.3 使用預設作用域

若想要所有的查詢皆使用某個預設的作用域,可以使用 default_scope

class Client < ActiveRecord::Base
  default_scope { where("removed_at IS NULL") }
end

當這個 Model 執行查詢時,執行的 SQL 會像是:

SELECT * FROM clients WHERE removed_at IS NULL

如果預設作用域需要做更複雜的事,可以用類別方法來取代:

class Client < ActiveRecord::Base
  def self.default_scope
    # Should return an ActiveRecord::Relation.
  end
end

14.4 移除所有作用域

如果想移除作用域,可以使用 unscoped 方法。這在特定查詢不需要使用 default_scope 時特別有用。

Client.unscoped.load

unscoped 會移除所有的作用域,回到原本正常的資料表查詢。

注意把 unscopedscope 連起來用是無效的。這種情況下推薦使用 unscoped 的區塊形式:

Client.unscoped {
  Client.created_before(Time.zone.now)
}

15 動態查詢方法

Rails 4.0 已棄用動態查詢方法,並在 4.1 移除這些方法。最佳實踐是使用 Active Record 的 scope 來取代。可以在 activerecord-deprecated_finders Gem 找到這些棄用的方法。

每個資料表裡定義的欄位(又稱屬性),Active Record 都提供一個 Finder 方法。假設 Client Model 有 first_name,則 Active Record 便會有 find_by_first_name 方法可用。若 Client Model 有 locked,則 Active Record 便會有 find_by_locked 方法可用。

在動態查詢方法名稱最後加上驚嘆號(!),可以獲得對應的 BANG 版本,即未找到符合的記錄時,會拋出 ActiveRecord::RecordNotFound 異常,像是 Client.find_by_name!("Ryan")

如果同時想找多個欄位,可以在方法名中間使用 and 連起來,比如:Client.find_by_first_name_and_locked("Ryan", true)

16 尋找或新建物件

在找不到記錄情況,新建一個物件是很常見的需求。可以透過 find_or_create_byfind_or_create_by! 來實作。

16.1 find_or_create_by

find_or_create_by 方法檢查指定屬性的記錄是否存在。不存在便呼叫 create,看個例子。

假設想找到名稱是 'Andy' 的客戶,沒找到便新建。可以這麼做:

Client.find_or_create_by(first_name: 'Andy')
# => #<Client id: 1, first_name: "Andy", orders_count: 0, locked: true, created_at: "2011-08-30 06:09:27", updated_at: "2011-08-30 06:09:27">

這個方法產生的 SQL 看起來像是:

SELECT * FROM clients WHERE (clients.first_name = 'Andy') LIMIT 1
BEGIN
INSERT INTO clients (created_at, first_name, locked, orders_count, updated_at) VALUES ('2011-08-30 05:22:57', 'Andy', 1, NULL, '2011-08-30 05:22:57')
COMMIT

find_or_create_by 會回傳已存在的紀錄,或是新建一筆記錄。在上面的例子裡,沒有找到 Andy 這個客戶,便新建一筆再回傳。

新記錄可能沒有存至資料庫;這取決於驗證是否通過(就像 create 一樣)。

假設我們想新建客戶時把 locked 屬性設為 false,但不想要包含在查詢裡。也就是沒找到叫 Andy 的客戶時,新建一位未鎖定的 Andy 客戶。有兩種方法可以做到,第一種是使用 create_with

Client.create_with(locked: false).find_or_create_by(first_name: 'Andy')

第二種是使用區塊:

Client.find_or_create_by(first_name: 'Andy') do |c|
  c.locked = false
end

區塊只在新建客戶時執行,已有客戶便會忽略掉區塊。

16.2 find_or_create_by!

也可以使用 find_or_create_by! 在建立的新紀錄為無效記錄時拋出異常。本文未涵蓋有關驗證的內容,但假設你不小心把這行加到了 Client Model:

validates :orders_count, presence: true

若沒有傳入 orders_count 而要建立新客戶時,則會拋出 ActiveRecord::RecordInvalid 異常:

Client.find_or_create_by!(first_name: 'Andy')
# => ActiveRecord::RecordInvalid: Validation failed: Orders count can't be blank

16.3 find_or_initialize_by

find_or_initialize_by 方法的工作原理與 find_or_create_by 相同,但沒找到時會用 new 而不是 create。這表示新紀錄會放在記憶體,不會存到資料庫。沿用 find_or_create_by 例子 ,假設我們現在想找叫做 Nick 的客戶:

nick = Client.find_or_initialize_by(first_name: 'Nick')
# => <Client id: nil, first_name: "Nick", orders_count: 0, locked: true, created_at: "2011-08-30 06:09:27", updated_at: "2011-08-30 06:09:27">

nick.persisted?
# => false

nick.new_record?
# => true

由於這個物件還沒存到資料庫,產生出來的 SQL 像是:

SELECT * FROM clients WHERE (clients.first_name = 'Nick') LIMIT 1

當想存到資料庫時,呼叫 save 即可:

nick.save
# => true

17 用 SQL 查詢

如果想用 SQL 在資料表裡找記錄可以使用:find_by_sqlfind_by_sql 方法會將查詢到的物件放在陣列裡回傳,即便只有一條記錄符合。比如可以執行以下查詢:

Client.find_by_sql("SELECT * FROM clients
  INNER JOIN orders ON clients.id = orders.client_id
  ORDER clients.created_at desc")
# =>  [
  #<Client id: 1, first_name: "Lucas" >,
  #<Client id: 2, first_name: "Jan" >,
  # ...
]

find_by_sql 提供自定查詢的簡單方式,並會將取出的物件實體化。

17.1 select_all

find_by_sql 有個類似的方法:connection#select_allselect_all 會使用自定的 SQL 語句從資料庫取出物件,但不會實體化物件。會回傳一個 ActiveRecord::Result 物件,可以使用 to_aryto_hashActiveRecord::Result 轉成陣列,每筆記錄皆是陣列裡的一個 Hash。

Client.connection.select_all("SELECT first_name, created_at FROM clients WHERE id = '1'")
# => [
  {"first_name"=>"Rafael", "created_at"=>"2012-11-10 23:23:45.281189"},
  {"first_name"=>"Eileen", "created_at"=>"2013-12-09 11:22:35.221282"}
]

17.2 pluck

pluck 可以用來查詢資料表的一個或多個欄位。接受欄位名稱作為參數,並回傳由指定欄位值所組成的陣列。

Client.where(active: true).pluck(:id)
# SELECT id FROM clients WHERE active = 1
# => [1, 2, 3]

Client.distinct.pluck(:role)
# SELECT DISTINCT role FROM clients
# => ['admin', 'member', 'guest']

Client.pluck(:id, :name)
# SELECT clients.id, clients.name FROM clients
# => [[1, 'David'], [2, 'Jeremy'], [3, 'Jose']]

以下程式碼:

Client.select(:id).map { |c| c.id }
# or
Client.select(:id).map(&:id)
# or
Client.select(:id, :name).map { |c| [c.id, c.name] }

可以用 pluck 取代:

Client.pluck(:id)
# or
Client.pluck(:id, :name)

select 不同,pluck 直接將從資料庫查詢的結果,轉成 Ruby 的 Array,而沒有建出 ActiveRecord 物件。可大幅提昇常用、大量查詢的執行效能。但任何 Model 可用的方法便無法使用了,如:

class Client < ActiveRecord::Base
  def name
    "I am #{super}"
  end
end

Client.select(:name).map &:name
# => ["I am David", "I am Jeremy", "I am Jose"]

Client.pluck(:name)
# => ["David", "Jeremy", "Jose"]

此外,pluck 不像 select 與其他 Relation 作用域,pluck 會直接觸發查詢,無法供之後的作用域連鎖使用,但可以與已經建立的作用域連鎖使用:

Client.pluck(:name).limit(1)
# => NoMethodError: undefined method `limit' for #<Array:0x007ff34d3ad6d8>

Client.limit(1).pluck(:name)
# => ["David"]

17.3 ids

ids 可以用來 pluck 所有 ID(取得資料表所有的主鍵):

Person.ids
# SELECT id FROM people

class Person < ActiveRecord::Base
  self.primary_key = "person_id"
end

Person.ids
# SELECT person_id FROM people

18 物件存在性

想檢查物件是否存在,可以使用 exists?exists 會使用與 find 相同的 SQL 語句查詢資料庫,但不會回傳物件集合,而是回傳 truefalse

Client.exists?(1)

exists 方法可接受多個數值,但只要有一個記錄存在,便會回傳 true

Client.exists?(id: [1,2,3])
# or
Client.exists?(name: ['John', 'Sergei'])

exists? 不傳任何參數也可以。

Client.where(first_name: 'Ryan').exists?

如果至少有一位客戶名稱是 'Ryan' 則回傳 true,否則回傳 false

Client.exists?

clients 資料表為空時回傳 false,反之 true

any?many? 也可以用來檢查 Model 或 Relation 的存在性。

# via a model
Article.any?
Article.many?

# via a named scope
Article.recent.any?
Article.recent.many?

# via a relation
Article.where(published: true).any?
Article.where(published: true).many?

# via an association
Article.first.categories.any?
Article.first.categories.many?

19 計算

本節以 count 為例,count 適用的選項所有子章節亦適用。

所有計算方法都可直接在 Model 上呼叫:

Client.count
# SELECT count(*) AS count_all FROM clients

或在 Active Record Relation 呼叫:

Client.where(first_name: 'Ryan').count
# SELECT count(*) AS count_all FROM clients WHERE (first_name = 'Ryan')

也可以對 Active Record Relation 使用不同的查詢方法,來做複雜的計算:

Client.includes("orders").where(first_name: 'Ryan', orders: {status: 'received'}).count

會執行下面的 SQL:

SELECT count(DISTINCT clients.id) AS count_all FROM clients
  LEFT OUTER JOIN orders ON orders.client_id = client.id WHERE
  (clients.first_name = 'Ryan' AND orders.status = 'received')

19.1 計數

想知道 Model 資料表裡有多少筆記錄,呼叫 Client.count 即可。也可以查詢特定欄位有幾筆記錄:Client.count(:age)

可用選項請參考計算一節。

19.2 平均

如果想找出資料表特定欄位的平均值,使用 average 方法:

Client.average("orders_count")
Client.average(:orders_count)

會回傳指定欄位的平均值,可能是浮點數(比如 3.14159265)。

可用選項請參考計算一節。

19.3 最小值

如果想找出資料表特定欄位的最小值,使用 min 方法:

Client.minimum("age")
Client.minimum(:age)

可用選項請參考計算一節。

19.4 最大值

如果想找出資料表特定欄位的最大值,使用 max 方法:

Client.maximum("age")
Client.maximum(:age)

可用選項請參考計算一節。

19.5 和

如果想找出資料表裡某欄位所有記錄的和,使用 sum 方法:

Client.sum("orders_count")
Client.sum(:orders_count)

可用選項請參考計算一節。

20 執行 EXPLAIN

可以對 Active Record Relation 使用 explain,比如:

User.where(id: 1).joins(:articles).explain

可能輸出如下(MySQL):

EXPLAIN for: SELECT `users`.* FROM `users` INNER JOIN `articles` ON `articles`.`user_id` = `users`.`id` WHERE `users`.`id` = 1
+----+-------------+----------+-------+---------------+
| id | select_type | table    | type  | possible_keys |
+----+-------------+----------+-------+---------------+
|  1 | SIMPLE      | users    | const | PRIMARY       |
|  1 | SIMPLE      | articles | ALL   | NULL          |
+----+-------------+----------+-------+---------------+
+---------+---------+-------+------+-------------+
| key     | key_len | ref   | rows | Extra       |
+---------+---------+-------+------+-------------+
| PRIMARY | 4       | const |    1 |             |
| NULL    | NULL    | NULL  |    1 | Using where |
+---------+---------+-------+------+-------------+

2 rows in set (0.00 sec)

Active Record 會根據使用的資料庫不同,按照資料庫 Shell 的方式印出。在 PostgreSQL 可能會輸出:

EXPLAIN for: SELECT "users".* FROM "users" INNER JOIN "articles" ON "articles"."user_id" = "users"."id" WHERE "users"."id" = 1
                                  QUERY PLAN
------------------------------------------------------------------------------
 Nested Loop Left Join  (cost=0.00..37.24 rows=8 width=0)
   Join Filter: (articles.user_id = users.id)
   ->  Index Scan using users_pkey on users  (cost=0.00..8.27 rows=1 width=4)
         Index Cond: (id = 1)
   ->  Seq Scan on articles  (cost=0.00..28.88 rows=8 width=4)
         Filter: (articles.user_id = 1)
(6 rows)

Eager loading 可能會觸發多條查詢,某些查詢依賴先前查詢的結果。由於這個原因,explain 會實際執行該查詢,並詢問要查詢那一個,比如:

User.where(id: 1).includes(:articles).explain

會輸出(MySQL):

EXPLAIN for: SELECT `users`.* FROM `users`  WHERE `users`.`id` = 1
+----+-------------+-------+-------+---------------+
| id | select_type | table | type  | possible_keys |
+----+-------------+-------+-------+---------------+
|  1 | SIMPLE      | users | const | PRIMARY       |
+----+-------------+-------+-------+---------------+
+---------+---------+-------+------+-------+
| key     | key_len | ref   | rows | Extra |
+---------+---------+-------+------+-------+
| PRIMARY | 4       | const |    1 |       |
+---------+---------+-------+------+-------+

1 row in set (0.00 sec)

EXPLAIN for: SELECT `articles`.* FROM `articles`  WHERE `articles`.`user_id` IN (1)
+----+-------------+----------+------+---------------+
| id | select_type | table    | type | possible_keys |
+----+-------------+----------+------+---------------+
|  1 | SIMPLE      | articles | ALL  | NULL          |
+----+-------------+----------+------+---------------+
+------+---------+------+------+-------------+
| key  | key_len | ref  | rows | Extra       |
+------+---------+------+------+-------------+
| NULL | NULL    | NULL |    1 | Using where |
+------+---------+------+------+-------------+


1 row in set (0.00 sec)

20.1 解讀 EXPLAIN

解讀 EXPLAIN 的輸出超出本指南的範疇。下面列出幾篇可能有用的文章:

反饋

歡迎幫忙改善指南的品質。

如發現任何錯誤之處,歡迎修正。開始貢獻前,可以先閱讀貢獻指南:文件

翻譯如有錯誤,深感抱歉,歡迎 Fork 修正,或至此處回報

文章可能有未完成或過時的內容。請先檢查 Edge Guides 來確定問題在 master 是否已經修掉了。再上 master 補上缺少的文件。內容參考 Ruby on Rails 指南準則來了解行文風格。

最後,任何關於 Ruby on Rails 文件的討論,歡迎至 rubyonrails-docs 郵件論壇