如果習慣寫純 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 方法有:
bindcreate_withdistincteager_loadextendingfromgrouphavingincludesjoinslimitlocknoneoffsetorderpreloadreadonlyreferencesreorderreverse_orderselectuniqwhere
以上方法皆會回傳一個 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_each 與 find_in_batches 方法專門用來解決大量記錄,處理無法一次放至記憶體的大量記錄。如果只是一千筆資料,使用平常的查詢方法便足夠了。
1.2.1 find_each
find_each 方法取出一批記錄,將每筆記錄傳入區塊裡。下面的例子,將以 find_each 來取出 1000 筆記錄(find_each 與 find_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_by 與 locked 欄位:
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 使用 limit 與 offset 方法 可以指定從第幾個記錄開始查詢。比如:
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 子句。
舉個例子,以下有 Category、Article、Comment、Guest 以及 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.find 與 includes 方法,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)
上例若文章都沒有評論,仍會載入所有文章。然而使用 joins (INNER JOIN)必須要滿足連接條件,不然不會回傳任何記錄。
14 作用域
作用域(Scopes)允許將常用查詢定義成關聯物件或 Model 的方法。作用域可以使用前面介紹過的 where、joins、includes 等方法。所有作用域方法會回傳一個 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_scope 被 scope 與 where 覆蓋掉了。
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 會移除所有的作用域,回到原本正常的資料表查詢。
注意把 unscoped 與 scope 連起來用是無效的。這種情況下推薦使用 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_by、find_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_sql。find_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_all。 select_all 會使用自定的 SQL 語句從資料庫取出物件,但不會實體化物件。會回傳一個 ActiveRecord::Result 物件,可以使用 to_ary 或 to_hash 將 ActiveRecord::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 語句查詢資料庫,但不會回傳物件集合,而是回傳 true 或 false。
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 的輸出超出本指南的範疇。下面列出幾篇可能有用的文章:
SQLite3: EXPLAIN QUERY PLAN
MySQL: EXPLAIN Output Format
PostgreSQL: Using EXPLAIN
反饋
歡迎幫忙改善指南的品質。
如發現任何錯誤之處,歡迎修正。開始貢獻前,可以先閱讀貢獻指南:文件。
翻譯如有錯誤,深感抱歉,歡迎 Fork 修正,或至此處回報。
文章可能有未完成或過時的內容。請先檢查 Edge Guides 來確定問題在 master 是否已經修掉了。再上 master 補上缺少的文件。內容參考 Ruby on Rails 指南準則來了解行文風格。
最後,任何關於 Ruby on Rails 文件的討論,歡迎至 rubyonrails-docs 郵件論壇。