Skip to content

Instantly share code, notes, and snippets.

@javierav
Last active April 12, 2023 11:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save javierav/f76703d34f9ece9c95a50e5aad09d688 to your computer and use it in GitHub Desktop.
Save javierav/f76703d34f9ece9c95a50e5aad09d688 to your computer and use it in GitHub Desktop.
Consultas de datos de varios modelos en Rails

Consultas de datos de varios modelos en Rails

Tipos de joins

En estos ejemplos la Tabla A tiene un has_many y la Tabla B un belongs_to y no tienen en cuenta que en la Tabla B podría haber varias referencias al mismo registro de la Tabla A, lo que produce duplicados de los registros de la Tabla A al hacer los joins.

Alias

  • INNER JOIN -> JOIN
  • LEFT JOIN -> LEFT OUTER JOIN
  • A RIGHT JOIN -> RIGHT OUTER JOIN
  • FULL JOIN -> FULL OUTER JOIN

Datos usados para los ejemplos

esquema de datos

ActiveRecord::Schema[7.0].define(version: 2022_07_11_100031) do
  create_table "comments", force: :cascade do |t|
    t.integer "post_id"
    t.text "text"
    t.string "status"
    t.index ["post_id"], name: "index_comments_on_post_id"
  end

  create_table "posts", force: :cascade do |t|
    t.string "title"
    t.string "status"
  end
end

seed

Post.create(title: 'Post 1', status: 'published').tap do |post|
  post.comments.create(text: 'Comment 1 in Post 1', status: 'approved')
  post.comments.create(text: 'Comment 2 in Post 1', status: 'approved')
  post.comments.create(text: 'Comment 3 in Post 1', status: 'pending')
end

Post.create(title: 'Post 2', status: 'draft').tap do |post|
  post.comments.create(text: 'Comment 1 in Post 2', status: 'approved')
  post.comments.create(text: 'Comment 2 in Post 2', status: 'rejected')
  post.comments.create(text: 'Comment 3 in Post 2', status: 'pending')
end

models

class Post < ApplicationRecord
  has_many :comments, dependent: :destroy
end

class Comment < ApplicationRecord
  belongs_to :post
end

Operaciones

Includes

Nos permite ahorrar consultas adicionales al traernos todos los datos que necesitamos cuando trabajamos con modelos relacionados.

Se usa para precargar, comparar y filtrar datos con datos asociados.

Ventajas

  • Usa LEFT OUTER JOIN para cargar los datos asociados.
  • Resuelve el problema de las consultas N+1

Inconvenientes

  • Al usar LEFT OUTER JOIN, los datos asociados pueden no ser todos los que realmente existen si han sido filtrados en la consulta.

Cuándo usarlo

Siempre que vayamos a hacer uso de los datos de la tabla relacionada, por ejemplo consultando sus campos, ya que evita el problema del N+1.

Ejemplos

Cuando no usamos includes:

Post.all.each do |post|
  post.comments.each do |comment|
    comment.text
  end
end
SELECT "posts".* FROM "posts"
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2

Cuando usamos includes en su forma básica, sólo ejecuta dos consultas:

posts = Post.includes(:comments)

posts.each do |post|
  post.comments.each do |comment|
    comment.text
  end
end
SELECT "posts".* FROM "posts"
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2)

Si añado la claúsulawhere para filtrar por datos del modelo Post, sigue usando dos consultas separadas para obtener los datos:

Post.includes(:comments).where(status: :published)
SELECT "posts".* FROM "posts" WHERE "posts"."status" = "published"

SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1

Si añado la claúsulawhere para filtrar por datos del modelo Comment, entonces Rails construye una consulta mediante el uso de LEFT OUTER JOIN:

Post.includes(:comments).where(comments: { status: :approved })
SELECT "posts"."id" AS t0_r0,
       "posts"."title" AS t0_r1,
       "posts"."status" AS t0_r2,
       "comments"."id" AS t1_r0,
       "comments"."post_id" AS t1_r1,
       "comments"."text" AS t1_r2,
       "comments"."status" AS t1_r3
FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"
WHERE "comments"."status" = "approved"

Cuando se usa includes, hay que usar los nombres de las relaciones.

eager_load

Nos permite cargar la asociación usando una sóla consulta con LEFT OUTER JOIN. Esto es exactamente lo mismo que hace includes cuando se ve forzada a usar una sóla consulta al aplicar un where o un order sobre la tabla que se está incluyendo.

Post.eager_load(:comments)
SELECT "posts"."id" AS t0_r0,
       "posts"."title" AS t0_r1,
       "posts"."status" AS t0_r2,
       "comments"."id" AS t1_r0,
       "comments"."post_id" AS t1_r1,
       "comments"."text" AS t1_r2,
       "comments"."status" AS t1_r3
FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id"

preload

Carga los datos asociados usando una consulta separada. Es exactamente lo mismo que hace includes en su forma básica.

Post.preload(:comments)
SELECT "posts".* FROM "posts"

SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2)

En este caso no podemos usar la cláusula where para filtrar los datos de comments, aunque si que podemos usarla para los datos de posts.

joins

Realiza un INNER JOIN entre las dos tablas. Se usa para comparar y filtrar datos usando los datos asociados.

Ventajas

  • Hace uso de INNER JOIN para cargar los datos asociados, por lo que los datos asociados no se ven afectados. Si un post tiene 12 comentarios, se devolverán los 12 comentarios al hacer un posts.first.comments.length.

Inconvenientes

  • No resuelve el problema del N+1 ya que no almacena los datos asociados. Se puede resolver aplicando un preload.
    Post.joins(:comments).where(comments: { status: "approved" }).preload(:comments)
  • Carga los datos en dos consultas separadas, cargando primero los datos del post y luego los comentarios.
  • Puede arrojar datos duplicados. Se puede resolver aplicando distinct.
    Post.joins(:comments).select('distinct posts.*')
    # OR
    Post.joins(:comments).distinct

Cuándo usarlo

Cuando sólo necesitemos filtrar u ordenar por datos de la tabla relacionada pero no usarlos. Hay que tener en cuenta que puede tener registros duplicados, para lo cual deberemos hacer uso del distinct.

Ejemplos

posts = Post.joins(:comments)

posts.each do |post|
  post.comments.each do |comment|
    comment.text
  end
end
SELECT "posts".* FROM "posts" INNER JOIN "comments" ON "comments"."post_id" = "posts"."id"
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2

En este caso además se estaría duplicando las filas que se devuelven para los posts, ya que por cada comentario que tiene un post, se devuelve duplicado el post:

Post.joins(:comments)

[
  #<Post:0x000000010ad0e5c0 id: 1, title: "Post 1", status: "published">,
  #<Post:0x000000010ad0e390 id: 1, title: "Post 1", status: "published">,
  #<Post:0x000000010ad0dd00 id: 1, title: "Post 1", status: "published">,
  #<Post:0x000000010ad0d878 id: 2, title: "Post 2", status: "draft">,
  #<Post:0x000000010ad0ce00 id: 2, title: "Post 2", status: "draft">,
  #<Post:0x000000010ad07900 id: 2, title: "Post 2", status: "draft">
]

Resumen

Operación Uso de join Carga en memoria de las asociaciones Uso de dos consultas
joins si (inner join) no no
preload no si si
includes si (left join) si depende
eager_load si (left join) si no

Fuente de datos:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment