Xử lí N+1 với polymorphic associations

Chắc mọi người đã quá quen thuộc với vấn đề N+1 và dùng eager loading để xử lí, nhưng hôm trước mình có gặp một trường hợp hơi rắc rối đó là xử lí eager loading polymorphic associations, google thì cũng ra được một cách, không biết có phải là hay nhất hay ko, nhưng cũng xin phép chia sẻ với mọi người.

Để ví dụ thì mình có một số model với quan hệ như sau:

class User < ApplicationRecord
  has_many :orders
end
class Shop < ApplicationRecord
  has_many :products
end
class Product < ApplicationRecord
  belongs_to :shop
  has_many :notis, as: :resource
end
class Order < ApplicationRecord
  belongs_to :user
  has_many :notis, as: :resource
end
class Noti < ApplicationRecord
  belongs_to :resource, polymorphic: true
end

Seed một ít dữ liệu:

5.times do |n|
  User.create name: "Nara-#{n+1}"
end

User.all.each do |user|
  10.times do
    user.orders.create
  end
end

5.times do |n|
  Shop.create name: "Shop-#{n+1}"
end

Shop.all.each do |shop|
  10.times do
    shop.products.create
  end
end

Product.all.each do |product|
  10.times do
    product.notis.create
  end
end

Order.all.each do |order|
  10.times do
    order.notis.create
  end
end

Chắc mình không cần giải thích nhiều về những model này, vậy vấn đề là gì?

Giả sử chúng ta cần xử lí đoạn code sau:

notis = Noti.all
notis.each do |noti|
  puts case noti.resource
  when Product
    noti.resource.shop.name
  when Order
    noti.resource.user.name
  end
end

Có thể thấy N+1 rõ ràng, mà không những N+1, mà là 2N+1

....
  Product Load (0.3ms)  SELECT  "products".* FROM "products" WHERE "products"."id" = ? LIMIT ?  [["id", 50], ["LIMIT", 1]]
  Shop Load (0.2ms)  SELECT  "shops".* FROM "shops" WHERE "shops"."id" = ? LIMIT ?  [["id", 5], ["LIMIT", 1]]
Shop-5
  Order Load (0.2ms)  SELECT  "orders".* FROM "orders" WHERE "orders"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  User Load (0.5ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Nara-1
....

Xử lí một tí:

notis = Noti.includes(:resource).all

Có vẻ tốt hơn, ko còn 2N+1, chỉ còn N+1 (yaoming)

....
    SELECT  "shops".* FROM "shops" WHERE "shops"."id" = ? LIMIT ?  [["id", 5], ["LIMIT", 1]]
Shop-5
Shop-5
    SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Nara-1
Nara-1
....

Giờ còn Shop với User, làm sao để eager load nó (??)

Tìm hiểu một hồi thì có người bày dùng ActiveRecord::Associations::Preloader

class Preloader
  def self.noti_preload notis
    preloader = ActiveRecord::Associations::Preloader.new
    preloader.preload notis.select{|noti| noti.resource_type.eql?(Product.name)}, {resource: :shop}
    preloader.preload notis.select{|noti| noti.resource_type.eql?(Order.name)}, {resource: :user}
    return
  end
end

Sử dụng nó:

notis = Noti.all
Preloader.noti_preload notis

Ngon ơ (hehe)

Noti Load (2.9ms)  SELECT "notis".* FROM "notis"
Product Load (0.8ms)  SELECT "products".* FROM "products" WHERE "products"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,.....)
Shop Load (0.9ms)  SELECT "shops".* FROM "shops" WHERE "shops"."id" IN (1, 2, 3, 4, 5)
Order Load (0.8ms)  SELECT "orders".* FROM "orders" WHERE "orders"."id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16,......)
User Load (1.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5)

Tìm hiểu một vòng lại thấy người ta khuyến cáo không nên dùng thằng này, vậy mọi người có cách nào hay hơn có thể chia sẽ ko ạ (hoho)

Tham khảo: https://ksylvest.com/posts/2017-08-23/eager-loading-polymorphic-associations-with-ruby-on-rails

Mr.Nara