Contando con rails, parte 2.

En el tutorial anterior vimos las diferencias entre size, count y length, en este capítulo vamos a trabajar con count, size, pluck y sufrir con los includes, eager_loads, joins y groups y los vamos a utilizar para generar gráficos.

A modo de repaso tenemos dos modelos, users y tweets, donde un tweet le pertenece a un usuario y cada usuario tiene muchos tweets.

Ahora si queremos saber cuantos Tweets tiene cada usuario, como lo hacemos? Una forma sencilla sería agruparlos y contar

Tweet.group(:user_id).count

Lo que nos daría como resultado:

[symple_highlight color=»gray»]=> {2=>20, 3=>20, 4=>20, 5=>20, 6=>20, 7=>20, 8=>20, 9=>20, 10=>20, 11=>20}[/symple_highlight]

Graficando la cuenta:

Teniendo el conteo de los Tweets podemos graficarlos.

Una forma sencilla de hacerlo es ocupando la gema de chartkick, que consiste en una especie de wrapper de google charts y de high charts, ahora ¿cuál escoger?, high charts a pesar de ser muy bueno no posee una licencia comercial gratuita a diferencia de google charts, así que para este ejemplo vamos a utilizar google charts.

Agregamos la gema, recordar que hay que reinciar el server después del bundle.

gem "chartkick"

Agregamos el javascript al layout, (la optimización de la carga de javascript está más allá del alcance de este tutorial).

<%= javascript_include_tag "//www.google.com/jsapi", "chartkick" %>

Y ya estamos listos para crear nuestro primer gráfico. Para eso en el controller users que tenemos, en el método index vamos a guardar el query anterior en una variable.

class UsersController < ApplicationController
  def index
    @users = User.all
    @tweets_count = Tweet.group(:user_id).count 
  end
end

y luego en la vista:

<%= column_chart @tweets_count %>

grafico de barras en rails

¿Qué podemos hacer para que en lugar del id del usuario aparezca el nombre

Aquí es donde se pone un poco más complicado el tema.

La forma más primitiva de hacerlo sería iterar sobre los resultados del count y cambiar los id por los nombres, pero eso requeriría hacer n consultas lo que significaría volver al problema de n+1, además después habría que hacer un merge de los resultados entonces no sólo sale más lento, sino que además es más trabajo.

Juntando las tablas.

El primer plan para todos los que han estudiado el problema de n+1 sería hacer un includes y luego armamos la data para generar el gráfico. Los datos resultantes tienen que ser de la forma [[nombre1, valor1],[nombre2, valor2]]

Entonces como lo hacemos?, un primer plan sería obtener los datos de la base de datos y luego agruparlos de esa forma, sería algo así:

User.includes(:tweets).collect{|user| [user.name, user.tweets.size]}

Utilizar count generaría un problema de n+1, tenemos que ocupar size o length.

La solución anterior es funcional, sin embargo todavía podemos mejorarla, podemos pedirle directamente a la base de datos que nos de los datos como queremos y de esta forma evitamos traer datos innecesarios a memoria y procesarlos. Para eso tenemos que agrupar los resultados.

Agrupar para contar

Agrupar para contar tiene una pequeña estrategia, tenemos dos tablas, de los usuarios queremos todos los datos, de los tweets queremos el conteo, pero agrupado por usuario, entonces necesitamos ocupar esas dos claves, del usuario el id y de los tweets el user_id.

User.includes(:tweets).group("users.id, tweets.user_id")

pero esto lamentablemente no funcionará, puesto que obtendremos un error del tipo:

ActiveRecord::StatementInvalid: SQLite3::SQLException: no such column: tweets.user_id: SELECT «users».* FROM «users» GROUP BY users.id, tweets.user_id

El problema causado se debe a que includes hace por defecto un preload en lugar de un eager load, y por lo mismo en el query de users rails todavía no sabe absolutamente nada de los tweets, (preload genera dos queries SQL por separado).

Sin embargo nosotros podemos ocupar directamente eager_load

User.eager_load(:tweets).group("users.id, tweets.user_id").count

Ahora si obtuvimos los resultados agrupados, pero no los que queríamos, los tweets están agrupados, y por lo tanto si contamos obtenemos sólo un tweet por cada usuario, que obviamente no es lo que queremos.

Contando con select

Para poder contar solo los tweets y no los usuarios con cada grupo de tweets, necesitamos poner el count de un select, de la tabla usuarios vamos a seleccionar todo, de la otra la cuenta.

User.eager_load(:tweets).group("users.id, tweets.user_id").select("users.*, count(tweets.id) as cuenta_tweets")

Lo que técnicamente es un query correcto, pero con un problema, eager_load ignora los select cuando trae los resultados a memoria.

Esto lo podemos ver al iterar sobre los datos:

User.eager_load(:tweets).group("users.id, tweets.user_id").select("users.*, count(tweets.id) as cuenta_tweets").collect{|x| [x.name, x.cuenta_tweets]}

Obtendremos un error de que cuenta tweets no existe. de hecho si hacemos cualquier tipo de select veremos que es ignorado.

Entonces con preload tenemos un error de SQL y con eager_load, la única solución restante (y por suerte si funciona) es con un join

Uniendo las tablas con join

Podemos hacer el mismo experimento previo ocupando joins.

User.joins(:tweets).group("users.id, tweets.user_id").select("users.*, count(tweets.id) as cuenta_tweets").collect{|x| [x.name, x.cuenta_tweets]}

y eso nos dará el arreglo correcto para graficar, con un detalle que podría ser muy importante, join por defecto hace un inner join y eso quiere decir que si un usuario no tiene tweets no saldrá en los resultados, esto puede ser el comportamiento esperado o no, pero en el caso de que no lo sea, explicaremos como corregirlo.

[symple_column size=»one-half» position=»first» fade_in=»false»]

Inner Join
contando con inner_join
[/symple_column]

[symple_column size=»one-half» position=»last» fade_in=»false»]

Left Join
contando con left_join
[/symple_column]

Haciendo un left join.

Para hacer un left join tenemos que especificar contra que tabla y además mencionar que claves unen las tablas, el resto del query sigue igual.

User.joins("left join tweets on users.id = tweets.user_id").group("users.id, tweets.user_id").select("users.*, count(tweets.id) as cuenta_tweets").collect{|x| [x.name, x.cuenta_tweets]}

y con eso ya tenemos los resultados esperados, sin embargo todavía tenemos que iterar con el collect, pero las muy buenas noticias es que el paso de ahora es mucho más fácil y se escribe con mucho menos código.

gráfico con las tablas unidas

Simplificando la vida con Pluck.

Si no nos interesa obtener los resultados en una estructura de activerecord relation, podemos utilizar pluck, pluck es como el select, pero no nos devuelve los datos en la estructura, de hecho simplemente nos devuelve un arreglo.

Por ejemplo si solo quisiéramos los ids de usuarios, podríamos hacer un :

User.pluck(:id)

Ahora para obtener la cuenta de tweets asociadas a los usuarios en el formato que queremos, podemos hacerlo con:

@tweets_count = User.includes(:tweets).group("tweets.user_id").pluck("users.name, count(tweets.id)")

Agregando pluck y pidiendo datos de los tweets logramos que includes utilice un eager_load en lugar de un preload, y considerando que eager_load hace por default hace un left join y que pluck no sufre del mismo conflicto del select con esto si que si tenemos nuestro gráfico con nombres y optimizado.

gráfico con las tablas unidas