José Galisteo Ruiz

¯\_(ツ)_/¯

activerecord-preload_query

| Comments

Este post va sobre mi gema activerecord-preload_query y otras maneras de hacer lo mismo.

Activerecord nos provee los métodos preload, eager_load e includes para precargar relaciones y evitar hacer N+1 queries a la base de datos cuando queremos acceder a esta. También nos provee de counter_cache, el cual está muy bien, pero está muy imitado.

Pero que pasa si tenemos algunos cálculos mas complicados? Imaginemos que tenemos el siguiente código:

1
2
3
4
5
6
7
8
9
10
11
class Category < ActiveRecord::Base

  has_many :products

end

class Product < ActiveRecord::Base

  belongs_to :category, counter_cache: true

end

Gracias a counter_cache podemos hacer algo así:

1
2
3
Category.all.each do |category|
  p category.products_count
end

Y solo se hará una sola llamada.

Pero y si queremos saber por alguna razón el stock total de cada categoría? Imaginemos que Product tiene el atributo stock.

En la categoría podremos añadir un método products_stock como este:

1
2
3
def products_stock
  products.sum(:stock)
end

De esta manera, tendremos de nuevo el problema del N+1.

Solución con joins

Para evitarlo podríamos optar por algo como:

1
  @categories = Category.select('*, sum(products.stock) as products_stock').joins(:products)

Esto soluciona el problema del N+1, pero estamos sobrescribiendo el select, entonces a @categories no podremos enviarle muchos métodos de activerecord porque no estarán disponibles los campos que el espera.

Un ejemplo sencillo es con @categories.count y si en vez de ser Category lo que precede a select fuese un “scope” mucho más complicado como podría ser en el caso del scope producido por una búsqueda compleja seguramente acabaríamos rompiendo la query.

Además que incluso con este sencillo caso, @categories.count acaba dando un error al no estar presentes los campos que se esperaban, porque hemos sobreescrito el select.

Otro problema a tener en cuenta, pero no el principal es hacer joins de tablas muy grandes.

Consulta a parte

Para evitar los problemas de sobreescribir el select, lo mejor es hacer la consulta a parte, por ejemplo:

1
2
3
4
5
6
7
categories = Category.limit(10).includes(:foo, :bar).where('whatever = foo')

products_stock = Category.where(id: categories.map(&:id)).select('*, sum(products.stock) as products_stock').joins(:products).map{|x| [x.id, x.products_stock]}.to_h

categories.map do |category|
  category.define_singleton_method :products_stock, lambda { products_stock[category.id] }
end

Con preload_query

Con preload_query es básicamente igual, pero interceptando el método de ActiveRecord que se encarga de hacer la consulta, así no llamaremos a la base de datos hasta el momento en el que sea necesario.

Ejemplo de uso básico:

1
2
3
4
5
6
7
8
9
10
11
12
class Category < ActiveRecord::Base

  has_many :products

  class << self
    def sum_stock(ids)
      where(id: ids).group("categories.id").joins(:products).select("categories.id, sum(products.stock) AS sum_stock")
    end
  end
end

Category.preload_query(:sum_stock).map(&:sum_stock)

A la clase Category puede que nos interese implementarle el método sum_stock para cuando no se hace un preload.

Por ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Category < ActiveRecord::Base

  has_many :products

  class << self
    def preloaded_sum_stock(ids)
      where(id: ids).group("categories.id").joins(:products).select("categories.id, sum(products.stock) AS preloaded_sum_stock")
    end
  end

  def sum_stock
    try(:preloaded_sum_stock) || products.sum(:stock)
  end
end

Category.preload_query(:sum_stock).map(&:sum_stock)
Category.map(&:sum_stock)
Category.last.sum_stock

Me gustaría pensar un DSL para implementar este último ejemplo de forma mas sencilla, pero de momento no se me ocurre :P

Gemas similares

Estas gemas hace algo parecido, pero solo se encargan de los counts, quizás para ti sea suficiente.

Comments