07/09/2018, 18:15

Truy Vấn Chân Kinh - Phần hai: Truy vấn với điều kiện (where) và truy vấn theo lô ( Batches) trong Rails

1. Mở đầu Ở phần một của serie Truy Vấn Chân Kinh về truy vấn trong Rails, mình đã giới thiệu qua về khái niệm và vai trò của Active Record , cùng với việc trình bày và phân tích về một số phương thức truy vấn cơ bản như find , find_by , first , take . Sang phần này, mình sẽ trình bày chi ...

1. Mở đầu

Ở phần một của serie Truy Vấn Chân Kinh về truy vấn trong Rails, mình đã giới thiệu qua về khái niệm và vai trò của Active Record, cùng với việc trình bày và phân tích về một số phương thức truy vấn cơ bản như find, find_by, first, take. Sang phần này, mình sẽ trình bày chi tiết về truy vấn có điều kiện sử dụng với where, bên cạnh đó giới thiệu về kiểu truy vấn theo lô dùng với Batches.

Cũng như ở phần trước hay các phần khác của serie này, bài viết của mình sẽ thiên hướng trình bày kết hợp với phân tích chứ không phải là giới thiệu và dịch giải đơn thuần. Vì vậy hi vọng được các bạn sẽ chú ý theo dõi và cùng thảo luận thêm bằng cách bình luận ở phía bên dưới.

2. Tất tần tật về where

where(opts = :chain, *rest)

where là phương thức trong Rails cho phép bạn chỉ định một điều kiện để truy vấn các bản ghi trả về. Nó đại diện cho phần WHERE trong câu lệnh SQL thường gặp.

where trả về một một tập bản ghi - một ActiveRecord::Relation, chính là kết quả lọc theo điều kiện khai báo ở đối số của nó trên tập bản ghi hiện tại.

Phương thức where có thể sử dụng điều kiện ở nhiều dạng khác nhau như là string, array và hash. Chúng ta sẽ cùng tìm hiểu kỹ hơn về các cách khai báo này.

2.1 String conditions

Cách này khá đơn giản, để đặt điều kiện cho câu truy vấn chỉ cần đặt vào trong phương thức where một biểu thức điều kiện dạng string. Kiểu như thế này:

User.where("city = 'Hanoi'")
# SELECT * from users where city = 'Hanoi';

Câu này sẽ trả về tất cả user có địa chỉ ở thành phố Hà Nội.

Tuy nhiên, sự đơn giản này lại mang đến một rủi ro khá lớn: nó quá dễ bị tấn công. Bạn chắc chắn đã nghe qua về SQL injection. Đây là một trong những kiểu tấn công dữ liệu phổ biến nhất mà có quá nhiều bài học và kinh nghiệm thú vị xung quanh nó. Dưới đây là một ví dụ kinh điển về SQL injection trong Rails với phương thức where.

params[:name] = "') OR 1--"
User.where("name = '#{params[:name]}' AND password = '#{params[:password]}'")
Query
SELECT "users".* FROM "users" WHERE (name = ') OR 1--' AND password = ')
Result
#<ActiveRecord::Relation [#<User id: 85, name: "Bob", password: 
"Bobpass", age: 76, admin: false, created_at: "2016-11-11 18:51:41", 
updated_at: "2016-11-11 18:51:41">, #<User id: 86, name: "Jim", 
password: "Jimpass", age: 62, admin: false, created_at: "2016-11-11 
18:51:41", updated_at: "2016-11-11 18:51:41">, #<User id: 87, name: 
"Sarah", password: "Sarahpass", age: 41, admin: false, created_at: 
"2016-11-11 18:51:41", updated_at: "2016-11-11 18:51:41">, #<User id: 
88, name: "Tina", password: "Tinapass", age: 31, admin: false, 
created_at: "2016-11-11 18:51:41", updated_at: "2016-11-11 18:51:41">, 
#<User id: 89, name: "Tony", password: "Tonypass", age: 21, admin: 
false, created_at: "2016-11-11 18:51:41", updated_at: "2016-11-11 
18:51:41">, #<User id: 90, name: "Admin", password: "supersecretpass", 
age: 20, admin: true, created_at: "2016-11-11 18:51:41", updated_at: 
"2016-11-11 18:51:41">]>

Tóm lại, bạn không nên sử dụng các phương thức truy vấn hay thêm sửa xóa với đối số là một string thuần. Có cả tá phương thức mà SQL Injection có thể lợi dụng tấn công vào dữ liệu của bạn được nêu ra ở đây, bạn đọc có thể tìm hiểu để biết thêm chi tiết. Phần sau là những cách được recommend là an toàn hơn.

2.2 Array Conditions

User.where(["name = ? and email = ?", "Joe", "[email protected]"])

Khi một array được truyền vào như thế này, phần tử đầu tiên của nó được hiểu là mẫu câu truy vấn, các phần tử còn lại sẽ được chèn vào thay thế các dấu "?" trong mẫu để sinh ra điều kiện hoàn chỉnh. Đương nhiên lúc này, Active Record sẽ lo việc xây dựng câu truy vấn để tránh tấn công injection. Bên cạnh đó ở dữ liệu dạng Ruby truyền vào sẽ được chuyển sang kiểu dữ liệu trong database. Các phần tử được truyền vào chuỗi theo thứ tự mà nó xuất hiện. Quay trở lại ví dụ ở trên, câu lệnh SQL sinh ra sẽ như sau:

User.where(["name = ? and email = ?", "Joe", "[email protected]"])
# SELECT * FROM users WHERE name = 'Joe' AND email = '[email protected]';

Một cách khác nữa là bạn có thể dùng placeholders (dấu hiệu thay thế) được đặt tên trong mẫu, bằng cách truyền vào một hash ở phần tử thứ hai của array. Khi đó những placeholders sẽ được thay thế bởi giá trị của hash có key trùng với tên của nó trong mẫu.

User.where(["name = :name and email = :email", { name: "Joe", email: "[email protected]" }])
# SELECT * FROM users WHERE name = 'Joe' AND email = '[email protected]';

Ưu điểm của cách làm này là khiến code dễ đọc hơn, thường sẽ áp dụng đối với những câu truy vấn phức tạp.

Bạn cũng có thể truyền vào where nhiều đối số mà không có bọc [] của một array, khi đó chúng vẫn sẽ được hiểu như các phần tử của một mảng duy nhất.

User.where("name = :name and email = :email", { name: "Joe", email: "[email protected]" })
# SELECT * FROM users WHERE name = 'Joe' AND email = '[email protected]';

Lưu ý: Khi sử dụng các strings để định nghĩa điều kiện, bạn có thể sử dụng các toán tử có sẵn trong database. Mặc dù điều này cho phép tính linh động (flexibility) ở mức tối đa, nhưng bạn cũng có thể vô tình tạo ra một vài phụ thuộc (dependencies) ở tầng database bên dưới. Điều đó có nghĩa là code của bạn có thể hoạt động khác nhau ở những database khác nhau. Nếu bạn có ý định rằng đoạn code đó sẽ được sử dụng chung trên nhiều nền tảng, hãy test thử trên nhiều database backend khác nhau.

2.3 Hash Conditions

Active Record cũng cho phép chúng ta truyền vào một điều kiện dạng hash, như thế này:

User.where({ name: "Joe", email: "[email protected]" })
# SELECT * FROM users WHERE name = 'Joe' AND email = '[email protected]'

Cách này được sử dụng phổ biến nhất, dễ thấy bởi tính ngắn gọn và dễ đọc. Với điều kiện dạng hash, keys truyền vào chỉ tên trường tìm kiếm, dữ liệu tìm kiếm chính là giá trị ứng với key đó.

Tên trường dữ liệu có thể sử dụng string hoặc symbol. Tùy theo mục đích truy vấn, các giá trị truyền vào tương ứng có thể là các giá trị đơn, các arrays hay là ranges.

# Truy vấn với điều kiện bằng
User.where({ name: "Joe", email: "[email protected]" })
# SELECT * FROM users WHERE name = 'Joe' AND email = '[email protected]'

# Truy vấn với điều kiện tập con
User.where({ name: ["Alice", "Bob"]})
# SELECT * FROM users WHERE name IN ('Alice', 'Bob')

# Truy vấn với điều kiện giới hạn miền
User.where({ created_at: (Time.now.midnight - 1.day)..Time.now.midnight })
# SELECT * FROM users WHERE (created_at BETWEEN '2012-06-09 07:00:00.000000' AND '2012-06-10 07:00:00.000000')

Trong trường hợp sử dụng quan hệ belongs_to, ta có thể dùng khóa quan hệ để chỉ định một model nếu như giá trị truyền vào hash là một ActiveRecord object.

author = Author.find(1)

# Hai câu truy vấn dưới đây là tương đương nhau:
Post.where(author: author)
Post.where(author_id: author)

Điều này cũng đúng với quan hệ belongs_to kiểu polymorphic:

treasure = Treasure.create(name: 'gold coins')
treasure.price_estimates << PriceEstimate.create(price: 125)

# Hai câu truy vấn dưới đây là tương đương nhau:
PriceEstimate.where(estimate_of: treasure)
PriceEstimate.where(estimate_of_type: 'Treasure', estimate_of_id: treasure)

2.4 NOT Conditions and OR Conditions

Ở các loại khai báo điều kiện trên chúng ta dễ dàng có thể định nghĩa một điều kiện kết hợp AND sử dụng nhiều đối số. Vậy thì làm cách nào để có thể khai báo logic NOT hay OR trong câu điều kiện?

NOT Conditions

not(opts, *rest)

Đối với câu truy vấn NOT SQL, Rails sử dụng where.not:

Trainee.where.not(resigned: true)

Việc sử dụng where không kèm theo đối số truyền vào sẽ trả về một đối tượng WhereChain, mà có thể được nối với phương thức not để trả về một relation mới có ý nghĩa phủ định với mệnh đề where.

User.where.not(name: "Jon")
# SELECT * FROM users WHERE name != 'Jon'

not cũng sử dụng điều kiện dưới dạng string, array, hay hash. Ví dụ:

User.where.not("name = 'Jon'")
# SELECT * FROM users WHERE NOT (name = 'Jon')

User.where.not(["name = ?", "Jon"])
# SELECT * FROM users WHERE NOT (name = 'Jon')

User.where.not(name: "Jon")
# SELECT * FROM users WHERE name != 'Jon'

User.where.not(name: nil)
# SELECT * FROM users WHERE name IS NOT NULL

User.where.not(name: %w(Ko1 Nobu))
# SELECT * FROM users WHERE name NOT IN ('Ko1', 'Nobu')

User.where.not(name: "Jon", role: "admin")
# SELECT * FROM users WHERE name != 'Jon' AND role != 'admin'

OR Conditions

Việc sử dụng logic OR thì đơn giản hơn. Điều kiện OR giữa hai tập relations có thể xây dựng bằng cách gọi or trên tập thứ nhất và truyền vào tập thứ hai như một đối số.

Client.where(locked: true).or(Client.where(orders_count: [1,3,5]))
# SELECT * FROM clients WHERE (clients.locked = 1 OR clients.orders_count IN (1,3,5))

3. Xử lý truy vấn theo lô với Batches

Bạn đã nhiều lần nhìn thấy hay chính tay tạo ra những đoạn code như thế này:

User.all.each do |user|
  NewsMailer.weekly(user).deliver_now
end

Điều gì xảy ra khi chúng ta duyệt qua một tập bản ghi vô cùng lớn, kiểu như danh sách người dùng của một mạng xã hội hay ngân hàng câu hỏi của một trang web luyện thi trực tuyến? Điều dễ thấy là bộ nhớ bị một lượng dữ liệu khổng lồ chiếm dụng. Như ở ví dụ này, giả sử bảng Users có kích thước lớn, việc gọi User.all.each sẽ yêu cầu Active Record lấy ra toàn bộ bảng trong một lần truyền dữ liệu, tiếp tục xây dựng model đối với từng dòng và sau đó giữ toàn bộ mảng các đối tượng của model trong bộ nhớ. Điều này ảnh hưởng nghiêm trọng đến performance của hệ thống. Do vậy, chúng ta cần có cách để giải quyết tối ưu hơn.

Thật tốt là Rails có sẵn một module chuyên xử lý những trường hợp như thế này, đó là ActiveRecord::Batches. Nó cung cấp một số phương thức tuyệt vời để bạn có thể thao tác với cơ sở dữ liệu trên những tập bản ghi lớn, bằng việc chia nhỏ dữ liệu thành các lô (hay mẻ - batches) có kích thước phù hợp để xử lý, do đó có thể giảm được vấn đề tiêu thụ bộ nhớ đã nêu ở trên. Trong phạm vi của bài viết này, mình sẽ giới thiệu về hai phương thức quan trọng và khá thú vị của ActiveRecord::Batches, đó là find_eachfind_in_batches.

3.1 find_each

Phương thức find_each lấy ra một tập bản ghi dưới dạng các batch và truyền từng bản ghi vào block. Ví dụ ở trên được viết thành:

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

Mặc định find_each (và cả find_in_batches) đều sử dụng kích cỡ batch là 1000. Trong ví dụ trên, find_each lấy về tập các Users dưới dạng các batch cỡ 1000 bản ghi và truyền từng bản ghi một vào block. Quá trình đó được lặp lại, nó sẽ tiếp tục lấy thêm batch cho tới khi tất cả các bản ghi được xử lý.

find_each không chỉ dùng với model mà còn dùng được trên relations:

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

Nếu bạn không truyền cho find_each một block thì nó sẽ trả về một Enumerator để chain (nối) đến phương thức khác:

User.find_each.with_index do |user, index|
  user.count_something(index + 1)
end

3.2 find_in_batches

Phương thức find_in_batches gần như tương tự với find_each, khi cả hai cùng lấy ra các bản ghi theo từng batch. Điểm khác biệt đó chính là find_in_batches truyền vào block từng batch như một mảng các bản ghi thay vì truyền vào từng bản ghi một. Ví dụ dưới đây sẽ truyền vào block một mảng 1000 bản ghi đơn hàng mỗi lần (riêng với block cuối cùng sẽ là một mảng chứa các đơn hàng còn lại):

Invoice.find_in_batches do |invoices|
  export.add_invoices(invoices)
end

3.3 Các options cho find_each và find_in_batches

Cả hai phương thức trên đều sử dụng chung bộ options:

find_each(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil)
find_in_batches(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil)

Chúng ta sẽ cùng tìm hiểu ý nghĩa và cách sử dụng của chúng.

:batch_size

Option này cho phép bạn chỉ định số lượng bản ghi trả về trong một batch, trước khi được truyền tới block. Giá trị mặc định của nó là 1000. Ví dụ bạn muốn lấy ra các batches có số lượng bản ghi 5000, chỉ cần làm như sau:

User.find_each(batch_size: 5000) do |user|
  NewsMailer.weekly(user).deliver_now
end
# tương tự với find_in_batches

:start

Theo mặc định, các bản ghi được lấy ra theo thứ tự tăng dần của khóa chính, mà thường phải là số nguyên. Option này cho phép bạn chỉ định giá trị khóa chính (tạm gọi là ID) bắt đầu của dãy, khi mà bạn không cần những bản ghi có ID nhỏ hơn. Điều này đặc biệt hữu ích cho một số trường hợp, ví dụ khi bạn muốn quay lại một quá trình xử lý batch đang dang dở, giả sử trước đó bạn đã lưu được ID của bản ghi cuối cùng được xử lý để làm checkpoint.

User.find_each(start: 2000) do |user|
  NewsMailer.weekly(user).deliver_now
end
# tương tự với find_in_batches

:finish

Tương ứng với option :start, ta có option :finish cho phép chỉ định ID cuối cùng của dãy, khi mà bạn không cần những bản ghi có ID lớn hơn thế. Đây là lựa chọn tốt cho những trường hợp mà bạn muốn xử lý batch trên tập con của tập hợp bản ghi, bằng cách sử dụng :start và :finish.

User.find_each(start: 000, finish: 10000) do |user|
  NewsMailer.weekly(user).deliver_now
end

Một ví dụ khác là khi bạn muốn xử lý một lượng lớn dữ liệu với một tập các worker, trên cùng một hàng đợi. Chắc hẳn bạn sẽ muốn phân chia dữ liệu cho từng worker (ví dụ mỗi chú 10000 bản ghi), đơn giản chỉ cần sử dụng :start và :finish cho mỗi woker.

# Trong worker thứ nhất xử lý 9999 bản ghi đầu tiên
Person.find_each(finish: 9_999) do |person|
  person.party_all_night!
end

# Trong worker thứ hai xử lý từ bản ghi 10000 đến hết
Person.find_each(start: 10_000) do |person|
  person.party_all_night!
end

:error_on_ignore

Lưu ý: Thứ tự của tập là không thể thay đổi, nó tự động được cho là tăng dần theo khóa chính (“id ASC”) để batch có thể sử dụng. Điều này có nghĩa là các phương thức này chỉ hoạt động đúng khi khóa chính là trường có thể xếp thứ tự (ví dụ integer hay string).

Điều này cũng đồng nghĩa với việc dù cho relation hiện tại có sẵn một phép xếp thứ tự nào đó thì khi sử dụng bất cứ phương thức truy vấn batch nào ở trên, thứ tự batch lấy ra vẫn không thể bị khác đi so với luật (“id ASC”). Điều duy nhất bạn có thể làm là định nghĩa ra reaction cho việc đó bằng việc raise một error hay không.

Option :error_on_ignore sẽ đứng ra chỉ định việc đó. Trong trường hợp relation có tồn tại một phép xếp thứ tự, nếu giá trị của :error_on_ignore là nil thì sẽ không có error nào được raise và ngược lại, nó sẽ raise ra lỗi.

# rails/activerecord/lib/active_record/relation/batches.rb

def act_on_ignored_order(error_on_ignore)
  raise_error = (error_on_ignore.nil? ? klass.error_on_ignored_order : error_on_ignore)

  if raise_error
    raise ArgumentError.new(ORDER_IGNORE_MESSAGE)
  elsif logger
    logger.warn(ORDER_IGNORE_MESSAGE)
  end
end

Lưu ý: Phương thức find_eachfind_in_batches chỉ phù hợp sử dụng cho những trường hợp cần xử lý lượng bản ghi không đủ chứa hết trong bộ nhớ trong một lần truyền dữ liệu. Nếu bạn chỉ đơn giản là cần lặp qua lượng bản ghi không quá lớn (cỡ vài trăm đến vài ngàn) thì những phương thức truy vấn bình thường sẽ là lựa chọn thích hợp hơn.

4. Tổng kết

Trên đây là những kiến thức cốt lõi và mình tin là cũng khá đầy đủ mà mình đã tổng hợp được, liên quan đến việc sử dụng truy vấn điều kiện với where và truy vấn theo lô sử dụng Batches.

Một vài resources mà mình đã tham khảo như Active Record Querying, ActiveRecord::Batches, các bạn có thể đọc để tìm hiểu thêm.

Ở phần sau, mình sẽ trình bày về Chiếu chọn, Sắp xếp và Gom nhóm trong truy vấn dữ liệu với Rails, bao gồm việc sử dụng các phương thức: order, select, pluck, limit, offset, grouphaving.

Cảm ơn sự quan tâm của mọi người!

0