Eager loading trong Rails

Nếu bạn đang sử dụng Rails và ActiveRecord thì bạn đã biết đến includes , preloadeager_load. Liệu bạ``n có biết rõ về chúng và cách sử dụng chúng 1 cách hiệu quả. Đôi khi chúng ta có thể thấy 1 câu Query vô cùng đơn giản, nhưng đôi khi lại là 1 câu Query với nhiều tables và nhiều colums khác nhau. Vậy thì bài viết này của mình sẽ 1 phần nào giúp các bạn giải quyết được một số vấn đề như vây. Let's start!

Chúng ta hãy bắt đầu với 1 Active Record class và associations của nó. chúng ta sẽ sử dụng nó trong suốt bài này.

class User < ActiveRecord::Base
  has_many :addresses
end

class Address < ActiveRecord::Base
  belongs_to :user
end

và giờ có cái associations như thế rồi thì nên có cái data mà chơi chứ nhỉ? chúng ta sẽ seed cho nó tý tẹo. rob = User.create!(name: "Robert Pankowecki", email: "[email protected]") bob = User.create!(name: "Bob Doe", email: "[email protected]")

rob.addresses.create!(country: "Poland", city: "Wrocław", postal_code: "55-555", street: "Rynek") rob.addresses.create!(country: "France", city: "Paris", postal_code: "75008", street: "8 rue Chambiges") bob.addresses.create!(country: "Germany", city: "Berlin", postal_code: "10551", street: "Tiergarten")

Trong Rails 3

Khi bạn muốn sử dụng eager loading thì thông thường chúng ta hay sử dụng #includes. và nó sẽ thực hiện giống như sau:

User.includes(:addresses)

và câu Query của nó sẽ là:

#  SELECT "users".* FROM "users"
#  SELECT "addresses".* FROM "addresses" WHERE "addresses"."user_id" IN (1, 2)

vậy còn #preload#eager_load thì sao:

User.preload(:addresses)
#  SELECT "users".* FROM "users"
#  SELECT "addresses".* FROM "addresses" WHERE "addresses"."user_id" IN (1, 2)

Rõ ràng thì #includes#preload thực hiện khá giống nhau. còn đối với #eager_load

User.eager_load(:addresses)
#  SELECT
#  "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."email" AS t0_r2, "users"."created_at" AS t0_r3, "users"."updated_at" AS t0_r4,
#  "addresses"."id" AS t1_r0, "addresses"."user_id" AS t1_r1, "addresses"."country" AS t1_r2, "addresses"."street" AS t1_r3, "addresses"."postal_code" AS t1_r4, "addresses"."city" AS t1_r5, "addresses"."created_at" AS t1_r6, "addresses"."updated_at" AS t1_r7
#  FROM "users"
#  LEFT OUTER JOIN "addresses" ON "addresses"."user_id" = "users"."id"

Hình như nó đã thực hiện một cái gì đó khác thì phải? Thực ra thì thì Rails có 2 cách để preloading. Một cách là thực hiện tách rồi từng query để lấy data và một cách khác là thực hiện 1query và sử dụng left join để thực hiện lấy data. Như vậy nếu sử dụng #preload thì bạn muốn tách từng câu query còn #eager_load thì sẽ là 1 câu query. vậy #includes thì sao. cách nó thực hiện thế nào thực sự thì nó khác những thằng kia cái gì?

Hãy cùng xem ví dụ sau đây. :

User.includes(:addresses).where("addresses.country = ?", "Poland")
User.eager_load(:addresses).where("addresses.country = ?", "Poland")

# SELECT
# "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."email" AS t0_r2, "users"."created_at" AS t0_r3, "users"."updated_at" AS t0_r4,
# "addresses"."id" AS t1_r0, "addresses"."user_id" AS t1_r1, "addresses"."country" AS t1_r2, "addresses"."street" AS t1_r3, "addresses"."postal_code" AS t1_r4, "addresses"."city" AS t1_r5, "addresses"."created_at" AS t1_r6, "addresses"."updated_at" AS t1_r7
# FROM "users"
# LEFT OUTER JOIN "addresses"
# ON "addresses"."user_id" = "users"."id"
# WHERE (addresses.country = 'Poland')

Bạn có thể nhìn thấy, #includes có thể sử dụng câu điều khiện where với table được load trước và 2 các trên đều cho 1 cách thực thi như nhau. Chúng ta có thể thực hiện #includes như #eager_load với câu điều khiện where với tables được load trước. hay là sử dụng trực tiếp #eager_load.

Còn điều gì sẽ xảy ra với #preload khi thực hiện như thế :

User.preload(:addresses).where("addresses.country = ?", "Poland")
#  SELECT "users".* FROM "users" WHERE (addresses.country = 'Poland')
#
#  SQLite3::SQLException: no such column: addresses.country

Chúng ta bắt được 1 exception về việc User không thể biết được addresses là cái gì trong câu truy vấn trên.

Nó nói lên điều gì?

User.includes(:addresses).where("addresses.country = ?", "Poland")

với việc hiện hiện dòng code trên chúng ta sẽ được cái gì?

  • Chúng ta sẽ lấy được những user với addresses được cung cấp, và chỉ load trước address đó.
  • Chúng ta sẽ lấy được những user với addresses được cung cấp, và load trước tất cả address của các user đó.
  • Chúng ta sẽ lấy được tất cả users và với addresses được cung cấp của họ.

Cái đầu tiên là điều đương nhiên nhưng còn cái thứ 2 và 3 thì liệu có đúng. Keep going on.!

Preload có làm tốt?

mục đích của chúng ta là: Lấy những user và địa chỉ của họ, nhưng load trước tất cả address của họ. Tôi cần tất cả address của user có ít nhất một address là Poland

Vệc lấy users với address của họ được cung cấp có lẽ easy bằng việc thi: User.joins(:addresses).where("addresses.country = ?", "Poland") và chúng ta muốn load trược addresses của user đó bằng việc sử dụng includes(:addresses)

r = User.joins(:addresses).where("addresses.country = ?", "Poland").includes(:addresses)
r[0]
#=> #<User id: 1, name: "Robert Pankowecki", email: "[email protected]", created_at: "2017-09-28 11:26:24", updated_at: "2017-09-28 11:26:24">

r[0].addresses
# [
#   #<Address id: 1, user_id: 1, country: "Poland", street: "Rynek", postal_code: "55-555", city: "Wrocław", created_at: "2017-09-28 11:26:24", updated_at: "2017-09-28 11:26:24">
# ]

Cái này thực hiện không giống như cái chúng ta muốn. chúng ta không lấy được địa chỉ thứ 2 của user. Rails vẫn thực hiệ eager load nhưng điều khác biệt ở đây là sử dụng INNER JOIN thay vì LEFT JOIN Trong trường hợp này chúng ta sẽ sử dụng #preload thay cho #includes

r = User.joins(:addresses).where("addresses.country = ?", "Poland").preload(:addresses)
# SELECT "users".* FROM "users"
# INNER JOIN "addresses" ON "addresses"."user_id" = "users"."id"
# WHERE (addresses.country = 'Poland')

# SELECT "addresses".* FROM "addresses" WHERE "addresses"."user_id" IN (1)

r[0]
# [#<User id: 1, name: "Robert Pankowecki", email: "[email protected]", created_at: "2017-09-28 11:26:24", updated_at: "2017-09-28 11:26:24">]

r[0].addresses
# [
#  <Address id: 1, user_id: 1, country: "Poland", street: "Rynek", postal_code: "55-555", city: "Wrocław", created_at: "2017-09-28 11:26:24", updated_at: "2017-09-28 11:26:24">,
#  <Address id: 3, user_id: 1, country: "France", street: "8 rue Chambiges", postal_code: "75008", city: "Paris", created_at: "2017-09-28 11:26:24", updated_at: "2017-09-28 11:26:24">]
# ]

Với cách này chúng ta có thể lấy đúng mục đích. Việc sử dụng #preload chúng ta có thể lấy được data và load trước data của nó. Điều này làm câu query khá mình bạch và đơn giản.

Load trước thành phần con của associations

Mục đích của nó để giải quyết vấn đề thứ 3 là: Lấy tất cả User và address của nó được cung cấp. Ở đây tôi sử dụng add thêm condition association như sau:

class User < ActiveRecord::Base
  has_many :addresses
  has_many :polish_addresses, conditions: {country: "Poland"}, class_name: "Address"
end

và sử dung #preload như sau:

r = User.preload(:polish_addresses)

# SELECT "users".* FROM "users"
# SELECT "addresses".* FROM "addresses" WHERE "addresses"."country" = 'Poland' AND "addresses"."user_id" IN (1, 2)

r

# [
#   <User id: 1, name: "Robert Pankowecki", email: "[email protected]", created_at: "2017-09-28 11:26:24", updated_at: "2017-09-28 11:26:24">
#   <User id: 2, name: "Bob Doe", email: "[email protected]", created_at: "2017-09-28 11:26:25", updated_at: "2017-09-28 11:26:25">
# ]

r[0].polish_addresses

# [
#   #<Address id: 1, user_id: 1, country: "Poland", street: "Rynek", postal_code: "55-555", city: "Wrocław", created_at: "2017-09-28 11:26:50", updated_at: "2017-09-28 11:26:50">
# ]

r[1].polish_addresses
# []

hoặc là sử dụng #eager_load như thế này:

r = User.eager_load(:polish_addresses)

# SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."email" AS t0_r2, "users"."created_at" AS t0_r3, "users"."updated_at" AS t0_r4,
#        "addresses"."id" AS t1_r0, "addresses"."user_id" AS t1_r1, "addresses"."country" AS t1_r2, "addresses"."street" AS t1_r3, "addresses"."postal_code" AS t1_r4, "addresses"."city" AS t1_r5, "addresses"."created_at" AS t1_r6, "addresses"."updated_at" AS t1_r7
# FROM "users"
# LEFT OUTER JOIN "addresses"
# ON "addresses"."user_id" = "users"."id" AND "addresses"."country" = 'Poland'

r
# [
#   #<User id: 1, name: "Robert Pankowecki", email: "[email protected]", created_at: "2017-09-28 11:26:24", updated_at: "2017-09-28 11:26:24">,
#   #<User id: 2, name: "Bob Doe", email: "[email protected]", created_at: "2017-09-28 11:26:25", updated_at: "2017-09-28 11:26:25">
# ]

r[0].polish_addresses
# [
#   #<Address id: 1, user_id: 1, country: "Poland", street: "Rynek", postal_code: "55-555", city: "Wrocław", created_at: "2017-09-28 11:26:50", updated_at: "2017-09-28 11:26:50">
# ]

r[1].polish_addresses
# []

Trong Rails 4, 5

Trong Rails 4 thì chúng ta có thể viết như sau:

class User < ActiveRecord::Base
  has_many :addresses
  has_many :polish_addresses, -> {where(country: "Poland")}, class_name: "Address"
end

Và chúng ta có thể áp dụng như thế này trong Rails 4:

User.joins(:addresses).where("addresses.country = ?", "Poland").preload(:addresses)

User.preload(:polish_addresses)

Cả hai đều cho ra kêt quả như nhau.

Kết lại

Trong Rails có 3 cách để eager loading là : #includes #preload #eager_load

#includes sẽ là #preload hoặc #eager_load tùy thuộc vào việc có sử dụng điều kiện hay không. #preload sử dụng từng câu query đơn lẽ để lấy data từ DB #eager_load sử dụng 1 câu quey lớn với LEFT JOIN để liên kết nhiều tables.