Una presentación sobre cómo optimizar el código de tus modelos de ActiveRecord para que Postgres haga lo que mejor sabe hacer: Manejar, ordenar, filtrar e incluso generar datos.
2. …porque saben? Casi todos los proyectos que me han tocado tienen algunas
cosas que hacen que la vena se me salte y los ojos me sangren:
3. ¿Cuales son las órdenes (compras) con amplificadores y pedales "de
efectos" de la marca "Fender"?
Order Product Category
has_many
LineItem
belongs_to belongs_to
• quantity • name
• brand
• name
4. def index
category_ids = Category.where(name: params[:categories]).pluck(:id)
product_ids = Product.where(brand: params[:brand], category_id: category_ids).pluck(:id)
order_ids = LineItem.where(product_id: product_ids).pluck(:order_id).uniq
@orders = Order.where(id: order_ids)
end
:(
SELECT "id" FROM "categories" WHERE "name" IN ('amps', 'effects')
=> [25, 33]
SELECT "id" FROM "products" WHERE "category_id" IN (25, 33) AND "brand" = 'Fender'
=> [12, 44, 96, 35, 112, 134, 246, 288, 299, 315, 317, 447, 864, 882, 913, 1046]
SELECT "order_id" FROM "line_items" WHERE "product_id" IN (12, 44, 96, 35, 112, 134, 246,
288, 299, 315, 317, 447, 864, 882, 913, 1046)
=> [38, 423, 981 …… 88394] (count: 13000)
# SELECT * FROM "orders" WHERE "id" IN (38, 423, 981, …ridiculous amount of ids…, 88394)
…caso real. Los nombres cambiaron para proteger el anonimato.
5. def index
category_ids = Category.where(name: params[:categories]).map(&:id)
product_ids = Product.where(brand: params[:brand], category_id: category_ids).map(&:id)
order_ids = LineItem.where(product_id: product_ids).map(&:order_id).uniq
@orders = Order.where(id: order_ids)
end
:'(
SELECT * FROM "categories" WHERE "name" IN ('amps', 'effects')
=> [<Category id: 25, name: "amps">, <Category id: 33, name: "effects">] => [25, 33]
SELECT * FROM "products" WHERE "category_id" IN (25, 33) AND "brand" = 'Fender'
=> [<Product id: 12...>...] => [12, 44, 96, 112, 246, 288, 299,317, 447, 864, 913, 1046]
SELECT * FROM "line_items" WHERE "product_id" IN (12, 44, 96, 35, 112, 134, 246, 288,
299, 315, 317, 447, 864, 882, 913, 1046)
=> [<...>, ...thousands...] => [38, 423, 981 …… 88394] (count: 13000)
# SELECT * FROM "orders" WHERE "id" IN (38, 423, 981, …ridiculous amount of ids…, 88394)
…caso real. Los nombres cambiaron para proteger el anonimato.
7. "Fuente de toda maldad"
A. Usar ActiveRecord (o cualquier otro ORM) es ineficiente, usa
muchísima memoria y hace al sistema lento…
B. …es más, vamos a prohibirlo en la empresa! QUEDAN
PROHIBIDOS LOS ORM!!!
C. …y para que vean que es en serio, AHORA TODOS VAN A TENER
QUÉ USAR STORED PROCEDURES!!!!!
9. La verdadera fuente de toda maldad
• Hacer operaciones de filtrado / ordenamiento en la app en vez de la
base de datos.
• Evaluar listas gigantes de registros en la app, para filtrar.
• Consultar datos en la DB para inmediatamente generar / actualizar
registros desde la app.
• Mal diseño de la DB
10. La verdadera fuente de toda maldad
No usar la base de datos para manejar los datos.
11. ¿Porqué usar la base de datos?
• El servicio de base de datos debe tener más memoria para filtrar,
ordenar y generar registros que los procesos (dynos, etc) de la
aplicación.
• El engine de la base de datos tiene algoritmos mucho más
eficientes para ordenar y filtrar la información.
• La información para filtrar, ordenar y generar los registros
requeridos está más "cerca" / "a la mano" en el servidor de base de
datos, que la aplicación.
12. Tips pro
• Consultas avanzadas con ActiveRecord y Arel:
• Scopes, buenas relaciones y usar .merge
• JOINS con datos que no están en la base de datos*
• Usar "INSERT + SELECT"
13. Consultas avanzadas con ActiveRecord y Arel
Order Product Category
has_many
LineItem
belongs_to belongs_to
• quantity • name
• brand
• name
¿Cuales son las órdenes (compras) con amplificadores y pedales "de
efectos" de la marca "Fender"?
14. Consultas avanzadas con ActiveRecord y Arel
def index
category_ids = Category.where(name: params[:categories]).pluck(:id)
product_ids = Product.where(brand: params[:brand], category_id: category_ids).pluck(:id)
order_ids = LineItem.where(product_id: product_ids).pluck(:order_id).uniq
@orders = Order.where(id: order_ids)
end
Ya deja ésos pluck(:id) en paz...
15. Consultas avanzadas con ActiveRecord y Arel
...y usa joins + merge:
class Order < ActiveRecord::Base
has_many :line_items
has_many :products, through: :line_items
end
def index
categories = Category.where(name: params[:categories])
products = Product.where(brand: params[:brand]).joins(:categories).merge(categories)
@orders = Order.joins(:products)
end
# SELECT "categories".* FROM "categories" WHERE "categories"."name" IN ('Amps', ‘Effects')
# SELECT "orders".* FROM "orders"
# INNER JOIN "line_items" on "orders"."id" = "line_items"."order_id"
# INNER JOIN "products" on "line_items"."product_id" = "products"."id"
# SELECT "products".* FROM "products"
# INNER JOIN "categories" on "products"."category_id" = "categories"."id"
# WHERE "products"."brand" = 'Fender' AND "categories"."name" IN ('Amps', 'Effects')
.merge(products)
# INNER JOIN "categories" on "products"."category_id" = "categories"."id"
# WHERE "categories"."name" IN ('Amps', 'Effects') AND "products"."brand" = 'Fender'
16. Consultas avanzadas con ActiveRecord y Arel
...y entre más scopes, mejor!
class Product < ActiveRecord::Base
belongs_to :category
scope :with_categories, -> (categories) { joins(:categories).merge(categories) }
end
class Order < ActiveRecord::Base
has_many :line_items
has_many :products, through: :line_items
scope :with_products, -> (products) { joins(:products).merge(categories) }
end
def index
categories = Category.where(name: params[:categories])
products = Product.with_categories(categories).where(brand: params[:brand])
@orders = Order.with_products(products)
end
17. Consultas avanzadas con ActiveRecord y Arel
Ojo con el Eager Loading:
def index
categories = Category.where(name: params[:categories])
products = Product.with_categories(categories).where(brand: params[:brand])
@orders = Order.with_products(products)
end
# SELECT "orders".* FROM "orders"
# INNER JOIN "line_items" on "orders"."id" = "line_items"."order_id"
# INNER JOIN "products" on "line_items"."product_id" = "products"."id"
# INNER JOIN "categories" on "products"."category_id" = "categories"."id"
# WHERE "categories"."name" IN ('Amps', 'Effects') AND "products"."brand" = 'Fender'
18. Consultas avanzadas con ActiveRecord y Arel
Ojo con el Eager Loading:
def index
categories = Category.where(name: params[:categories])
products = Product.with_categories(categories).where(brand: params[:brand])
@orders = Order.with_products(products).includes(products: [:categories])
end
# SELECT “orders”.”id" AS t0_r0, “orders”.”user_id” AS t0_r1, “orders”.”created_at” AS t0_r2
# “products”.”id” AS t2_r0, “products”.”name” AS t2_r1, “categories”.”id” AS t3_r0,
# “categories”.”name” AS t3_r1
# FROM "orders"
# INNER JOIN "line_items" on "orders"."id" = "line_items"."order_id"
# INNER JOIN "products" as "t0" on "line_items"."product_id" = "products"."id"
# INNER JOIN "categories" on "products"."category_id" = "categories"."id"
# WHERE "categories"."name" IN ('Amps', 'Effects') AND "products"."brand" = 'Fender'
19. Consultas avanzadas con ActiveRecord y Arel
¿Reportes? Deja de generar los reportes en Rubylandia…
class Shelf < ActiveRecord::Base
has_many :line_items
def item_count
line_items.to_a.count
end
end
————————————————
<% @shelves.each do |shelf| %>
# SELECT “shelves”.* FROM “shelves”
# SELECT “line_items”.* FROM “line_items” WHERE “line_items”.”shelf_id” = 24
# SELECT “line_items”.* FROM “line_items” WHERE “line_items”.”shelf_id” = 25
# SELECT “line_items”.* FROM “line_items” WHERE “line_items”.”shelf_id” = 26
# SELECT ...
<li>Item Count: <%= shelf.item_count %>
<% end %>
20. Consultas avanzadas con ActiveRecord y Arel
…y que lo haga la base de datos usando GROUP BY, COUNT, etc:
class Shelf < ActiveRecord::Base
has_many :line_items
def self.with_item_counts
line_items_for_count = LineItem.arel_table.alias(""line_items_for_count"")
join_conditions = arel_table[:id].eq(line_items_for_count[:shelf_id])
join = arel_table.create_join(
line_items_for_count,
arel_table.create_on(join_conditions),
Arel::Nodes::OuterJoin
) # Usa `.to_sql` para reviser cómo cada object de Arel se traduce a SQL
joins(join).group(arel_table[:id]).select(
arel_table[Arel.star],
Arel::Nodes::NamedFunction.new("COUNT", [line_items_for_count[Arel.star]])
.as(""item_count"")
)
end
end
21. Consultas avanzadas con ActiveRecord y Arel
…y que lo haga la base de datos usando GROUP BY, COUNT, etc:
class ShelvesController < ApplicationController
def reports
@shelves = Shelf.with_item_counts
end
end
——————————————————
# app/views/shelves/reports.html.erb
<% @shelves.each do |shelf| %>
# SELECT “shelves”.*, COUNT(“line_items”.*) AS “item_count”
# FROM “shelves” LEFT JOIN “line_items” ON “shelves”.”id” = “line_items”.”shelf_id”
# => [<Shelf id=1, item_count=48>,<Shelf id=2, item_count=73>]
<li>Item Count: <%= shelf.item_count %>
<% end %>
22. Consultas avanzadas con ActiveRecord y Arel
Recuerda que todos los scopes son encadenables…
…y que todos los métodos de clase pueden regresar scopes!
class Product < ActiveRecord::Base
belongs_to :category
def self.categories
Category.joins(:products).merge(current_scope).distinct
end
end
Product.where(brand: "Fender").categories
# SELECT DISTINCT “categories”.*
# FROM “categories”
# INNER JOIN “products” ON “categories”.”id” = “products”.”category_id”
# WHERE “products”.”brand” = ‘Fender’
23. Consultas avanzadas con ActiveRecord y Arel
Recuerda que todos los scopes son encadenables…
…y que todos los métodos de clase pueden regresar scopes!
class Order < ActiveRecord::Base
has_many :line_items
has_many :products, through: :line_items
def self.products
Product.joins(:orders).merge(current_scope).distinct
end
end
Order.where(created_at: Date.today).products.categories
# SELECT DISTINCT “categories”.*
# FROM “categories”
# INNER JOIN “products” ON “categories”.”id” = “products”.”category_id”
# INNER JOIN “line_items” ON “products”.”id” = “line_items”.”product_id”
# INNER JOIN “orders” ON “line_items”.”order_id” = “orders”.”id”
# WHERE “products”.”brand” = ‘Fender’ AND “orders”.”created_at” = ‘2016-07-21’
24. JOINS con datos que no están en la base de datos
Cómo funciona la integración actual de
ElasticSearch y ActiveRecord…
25. JOINS con datos que no están en la base de datos
ElasticSearch
Postgres
Ruby App
extraer
ids
SELECT *
FROM “products”
WHERE “id” IN (…)
[
{ “id”: 25, “score”: 8.0 },
{ “id”: 33, “score”: 7.75 },
…
]
ordenar…
(Array)[<Product id:25…>, <Product id:33 …>]
26. JOINS con datos que no están en la base de datos
…y cómo mejorarlo?
27. JOINS con datos que no están en la base de datos
ElasticSearch
Postgres
Ruby App
SUPER COOL SQL QUERY :)
[
{ “id”: 25, “score”: 8.0 },
{ “id”: 33, “score”: 7.75 },
…
]
<ActiveRecord::Relation [<Product id:25…>, <Product id:33 …>]>
28. JOINS con datos que no están en la base de datos
- De ElasticSearch obtuvimos un array similar a éste:
es_results = [{ id: 25, score: 8.0 }, { id: 33, score: 7.75 }]
- Y éste es el query ideal para la base de datos
SELECT "products".*
FROM "products" INNER JOIN json_populate_recordset(
NULL::record_score,
'[{"id":25, "score":8.36}, {"id":88, "score":7.71}]'
) AS “record_scoring" ON "products"."id" =
"record_scoring"."id"
ORDER BY "record_scoring"."score" DESC
Recordset al que se
puede hacer un join…
…como una tabla
temporal para éste query
29. JOINS con datos que no están en la base de datos
class CreateRecordScoreType < ActiveRecord::Migration
def up
execute "CREATE TYPE "record_score" AS "
"("id" NUMERIC, "score" NUMERIC(8,4))"
end
def down
execute 'DROP TYPE IF EXISTS "record_score"'
end
end
#1: Crear Custom Type para poder generar nuestro recordset:
30. JOINS con datos que no están en la base de datos
# /app/models/concerns/sortable_by_given_scores.rb
module SortableByGivenScores
extend ActiveSupport::Concern
module ClassMethods
def scores_as_recordset(scores, recordset_alias = :record_scoring)
Arel::Nodes::NamedFunction.new("json_populate_recordset", [
Arel::Nodes::SqlLiteral.new("NULL::record_score"),
ActiveSupport::JSON.encode(scores)
]).as(""#{recordset_alias}"")
end
def by_given_scores(scores, recordset_alias = :record_scoring)…
end
end
- darkbone
#2: Crear módulo con scope:
31. JOINS con datos que no están en la base de datos
# /app/models/concerns/sortable_by_given_scores.rb
module SortableByGivenScores
extend ActiveSupport::Concern
module ClassMethods
def scores_as_recordset(scores, recordset_alias = :record_scoring)…
def by_given_scores(scores, recordset_alias = :record_scoring)
scoring = Arel::Table.new recordset_alias # Not a real table...
scoring_recordset = scores_as_recordset(scores, recordset_alias)
join_condition = scoring_recordset.create_on(scoring[:id].eq(arel_table[:id]))
join_clause = arel_table
.create_join(scoring_recordset, join_condition, Arel::Nodes::InnerJoin)
joins(join_clause).order(scoring[:score].desc)
end
end
end
- darkbone
#2: Crear módulo con scope:
32. JOINS con datos que no están en la base de datos
# /app/models/product.rb
class Product < ActiveRecord::Base
includes SortableByGivenScores
belongs_to :category
has_many :line_items
end
# Simularemos un hash de resultados de ElasticSearch:
es_results = [{ id: 25, score: 8.0 }, { id: 33, score: 7.75 }]
# Resultados (sin evaluar)
some_products = Product.by_given_scores(es_results).includes(:category)
# SELECT "products".* FROM "products" INNER JOIN
# json_populate_recordset(NULL::record_score, '[{"id":25, "score":8.36}, {"id":88,
# ”score":7.71}]') AS "record_scoring" ON "products"."id" = "record_scoring"."id" ORDER
# BY "record_scoring"."score" DESC)
# SELECT * FROM "categories" WHERE "id" IN (26, 44)
#3: Agregar módulo a modelo ‘Product’:
33. Usar "INSERT + SELECT"
Order Product
has_many
LineItem
belongs_to
• quantity • name
• brand
SoldItem
34. Usar "INSERT + SELECT"
Generar una lista de N “sold_items” por cada “line_item” de
una order, dependiendo del valor de “line_items”.“quantity”:
35. Usar "INSERT + SELECT"
INSERT INTO "sold_items" ("order_id", "product_id", "item_sort") (
SELECT
"orders"."id" AS "order_id",
"line_items"."product_id" AS "product_id",
generate_series(1, "line_items"."quantity") AS "item_sort"
FROM "orders" INNER JOIN "line_items" on "orders"."id" = "line_items"."id"
WHERE "orders"."id" = 26
)
Crear Sold Items a partir de los LineItems…
…y usar “generate_series” para “multiplicar” los registros:
36. Usar "INSERT + SELECT"
En Ruby:
class Order < ActiveRecord::Base
def register_sold_items…
private
def sold_items_projection # Regresa el “SELECT”
li_table = LineItem.arel_table
item_sort = Arel::Nodes::NamedFunction
.new("generate_series", [1, li_table[:quantity]]) # Genera el “generate_series”
self.class.arel_table
.join(li_table).on(arel_table[:id].eq(li_table[:order_id]))
.where(arel_table[:id].eq(id))
.project arel_table[:id].as(""order_id""),
li_table[:product_id],
item_sort.as(“"item_sort"")
end
end
37. Usar "INSERT + SELECT"
En Ruby:
class Order < ActiveRecord::Base
def register_sold_items # Genera y ejecuta el “INSERT”
insert_table = SoldItem.arel_table
insert_manager = Arel::InsertManager.new ActiveRecord::Base
insert_manager.into sold_items_table
insert_manager.columns.concat [ # Genera la lista de columnas del insert...
insert_table[:order_id], insert_table[:product_id], insert_table[:item_sort]
]
insert_manager.select sold_items_projection # agrega el “SELECT”
connection.insert insert_manager.to_sql # Ejecuta el “INSERT”
end
private
def sold_items_projection…
end
38. Usar "INSERT + SELECT"
En Ruby:
@order = Order.find(24)
# Ejecutar el insert en bulto:
@order.register_sold_items
39. Por continuar…
• Postgres: “Window Functions” y otras funciones:
• “row_count”, “unnest”
• UPDATE + JOIN
• Mejorar los índices / Generar índices más efectivos
• Mejorar el diseño de base de datos
40. Referencias
• Dan Shultz - "Mastering ActiveRecord and Arel"
• Konstantin Gredeskoul - "12-Step Program For Scaling Web
Applications on PostgreSQL"
• Konstantin Gredeskoul - "From Obvious to Ingenious: Incrementally
Scaling Web Applications on PostgreSQL"
• Parker Selbert - Folding Postgres Window Functions into Rails