In this article, I’ll show you how I used Active Record to fix the app’s problems and if there’s a limit on how to customize Active Record queries.
The sample app I’ll use will be a clone (in a base form) of Spotify or any music app. Let me explain how will be the tables relationship in our database.
--- title: DB Schema --- erDiagram songs ||--|{ ratings : "" songs { integer id string title integer duration integer progress } albums ||--|{ ratings : "" albums { integer id string title } artists ||--|{ ratings : "" artists { integer id string name string age } users ||--|{ ratings : "" users { integer id string name } songs ||--|{ associations : "" albums ||--|{ associations : "" artists ||--|{ associations : "" associations { integer song_id integer artist_id integer album_id } ratings { integer id string votable_type integer votable_id integer user_id integer vote }
All the songs
belong to an artist
and an album
. We also have the users
table who can vote or rate a song or album. The ratings
table has a polymorphic relation to songs
and albums
.
NOTE: A polymorphic relation is how Rails associate with more than one table simultaneously without the need to create a foreign key with the name of the association.
There are a lot of ways to analyze your code. In this article, I’ll make with these 3 ways:
To have these 3 ways to measure our code, we will use the Benchmark
module inside Ruby and the gems benchmark-ips
and benchmark-memory
.
First solution:
Using a combination of Active Record
and Ruby
methods:
Album.includes(:ratings).map { |album| album.ratings.length }.max
Second solution:
Using only Active Record
methods
Album.from(
Album.select(
"albums.*", "COUNT(ratings.id) AS rating_count"
).joins(:ratings).group("albums.id")
).maximum("rating_count")
Both return the same result: 20. What is the difference?
The first part of this solution uses an Active Record
method:
Album.includes(:ratings)
This expression executes the following SQL code:
SELECT "albums".* FROM "albums"
SELECT "ratings".* FROM "ratings" WHERE "ratings"."ratingable_type" = "Album"
AND "ratings"."ratingable_id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...)
The more albums we have in our database, the longer the ranking list we have.
The second part is
.map { |album| album.ratings.length }.max
The map
method iterates over the result of the includes
method we did before. Inside every album, we call all the ratings associated with that album, and we calculate (with Ruby) how many ratings have every album. Once we have the list with all the totals, with the max
method of the Enumerators
in Ruby, we can calculate the element with the max value inside the array.
Let’s break the solution in two parts, the first is inside the from
method.
Album.select(
"albums.*", "COUNT(ratings.id) AS rating_count"
).joins(:ratings).group("albums.id")
This will execute the following SQL code:
SELECT albums.*, COUNT(ratings.id) AS rating_count
FROM "albums" INNER JOIN "ratings"
ON "ratings"."votable_type" = "Album" AND "ratings"."votable_id" = "albums"."id"
GROUP BY "albums"."id"
Here we add the COUNT(rating.id)
column with the alias rating_count
to our query to count how many associated ratings have every album.
The second part is:
Album.from(...).maximum("rating_count")
This will execute the following SQL code:
SELECT MAX(rating_count) FROM (...) subquery
The first part of the query we explained becomes the subquery on which we will run another query. The query will get the maximum value of the rating_count
column.
Elapsed Time This is the code we will execute:
Benchmark.bmbm do |x|
x.report("Active Record + Ruby code") { first_solution }
x.report("Only Active Record") { second_solution }
end
Note: I’m using the
bmbm
method to execute the reports twice, so none of the methods will be affected, only for being the first.
This is the result:
Rehearsal -------------------------------------------------------------
Active Record + Ruby code 5.554467 0.788396 6.342863 ( 7.404660)
Only Active Record 0.003204 0.001894 0.005098 ( 0.741973)
---------------------------------------------------- total: 6.347961sec
user system total real
Active Record + Ruby code 5.042479 0.540590 5.583069 ( 5.776371)
Only Active Record 0.001858 0.000419 0.002277 ( 0.532489)
If we analyze the result, there is a slight time difference between the two solutions, with the second one being faster.
Memory This is the code we will execute:
Benchmark.memory do |x|
x.report("Active Record + Ruby code") { first_solution }
x.report("Only Active Record") { second_solution }
x.compare!
end
This is the result:
Calculating -------------------------------------
Active Record + Ruby code
585.804M memsize ( 0.000 retained)
6.099M objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Only Active Record 53.270k memsize ( 0.000 retained)
511.000 objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Comparison:
Only Active Record: 53270 allocated
Active Record + Ruby code: 585803866 allocated - 10996.88x more
If we look at this result, we can say that the first solution uses 1.54 times more memory than the second solution.
Iterations per second This is the code we will execute:
Benchmark.ips do |x|
x.report("Active Record + Ruby code") { first_solution }
x.report("Only Active Record") { second_solution }
x.compare!
end
This is the result:
Warming up --------------------------------------
Active Record + Ruby code
1.000 i/100ms
Only Active Record 1.000 i/100ms
Calculating -------------------------------------
Active Record + Ruby code
0.159 (± 0.0%) i/s - 1.000 in 6.277377s
Only Active Record 12.768 (± 7.8%) i/s - 64.000 in 5.032453s
Comparison:
Only Active Record: 12.8 i/s
Active Record + Ruby code: 0.2 i/s - 80.15x (± 0.00) slower
As we see in the results, the first solution is 1.44 times faster than the second one.
So why is the second solution the best? Because we are doing only one call on the database, and the whole operation is done from the DB side.
This time I have 3 solutions:
First solution
Using Active Record
and Ruby
ratings = Rating
.where(votable_type: "Song")
.group(:votable_id).average(:vote)
.sort_by { |r| -r[1] }.take(n).to_h
songs = Song.includes(:artists).find(ratings.keys)
ratings.map do |song_id, rating|
song = songs.find { |song| song.id == song_id }
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: rating.to_f
}
end
Second solution
Using only one query with a lot of Active Record
methods
Song
.includes(:artists).joins(:ratings)
.select("songs.*, AVG(ratings.vote) as rating_avg")
.group("songs.id").order("rating_avg DESC").limit(n)
.map do |song|
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: song.rating_avg
}
end
Third solution
Using 2 queries with Active Record
methods and Ruby
code
ratings = Rating
.select("ratings.votable_id, AVG(ratings.vote) as rating_avg")
.where(votable_type: "Song")
.group(:votable_id).order("rating_avg DESC")
.limit(n)
Song
.includes(:artists)
.find(ratings.map(&:votable_id))
.map do |song|
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: ratings.find { |rating| rating.votable_id == song.id }.rating_avg
}
end
To calculate the songs with the highest rating, I first search in the ratings
table for the ratings related to the songs table.
Remember that the ratings
table has a polymorphic relationship with songs
, so we must look for those ratings whose votable_type
is Song
, so we will know which ratings are songs. Then we will group them by votable_id
(song.id) to obtain the average of all the ratings.
Rating
.where(votable_type: "Song")
.group(:votable_id)
.average(:vote)
This expression will return an array of arrays with the following structure: [[votable_id, vote_avg], [votable_id, vote_avg]]
. It has all the songs’ ids and the average of their ratings.
It will sort them by the second element of the array in descending order, then take the first ‘n’ elements and convert it into a hash.
.sort_by { |r| -r[1] }.take(n).to_h
The second part will get the songs of the hash we just generated, including the associated artists.
songs = Song.includes(:artists).find(ratings.keys)
Lastly, we iterate over the hash of the ratings to generate the required data. In every iteration, we search for the song which belongs to the rating, and we build the hash to return as a result.
ratings.map do |song_id, rating|
song = songs.find { |song| song.id == song_id }
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: rating.to_f
}
end
I tried to get the same result in this solution by doing only one query to the database.
First, I’ll make a query over the joins
of the tables songs
and ratings
, so I can have the data from both tables. I’ll use the includes(:artists)
method to call all the associated artists to the found songs.
Song.includes(:artists).joins(:ratings)
Over the joins
, we will ask for the average (in SQL) grouped by song.id
, then sort them by rating_avg
and at the end call only the first n
records.
.select("songs.*, AVG(ratings.vote) as rating_avg")
.group("songs.id")
.order("rating_avg DESC")
.limit(n)
Finally, we will iterate over the result to build the hash we should return as a result
.map do |song|
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: song.rating_avg
}
end
We make something similar to the first solution, but we don’t use the average
method from ActiveRecord
. Otherwise, we will do it by SQL using the select
method. We call the group
method to make the calculation for every song, then we filter the search to only songs with the where
method, and lastly, we sort them and limit the result to the n
first records
ratings = Rating
.select("ratings.votable_id, AVG(ratings.vote) as rating_avg")
.where(votable_type: "Song")
.group(:votable_id)
.order("rating_avg DESC")
.limit(n)
Then, we search the songs with those ratings we found, and we format them to result in the expected result:
Song
.includes(:artists)
.find(ratings.map(&:votable_id))
.map do |song|
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: ratings.find { |rating| rating.votable_id == song.id }.rating_avg
}
end
Elapsed Time
Rehearsal ----------------------------------------------------------------
Active Record + Ruby code 0.787713 0.196014 0.983727 ( 1.645864)
Only Active Record 0.016700 0.002986 0.019686 ( 0.567646)
Active Record + Ruby code v2 0.024173 0.000953 0.025126 ( 0.403639)
------------------------------------------------------- total: 1.028539sec
user system total real
Active Record + Ruby code 0.595617 0.026087 0.621704 ( 1.016591)
Only Active Record 0.008948 0.000577 0.009525 ( 0.580103)
Active Record + Ruby code v2 0.002977 0.000137 0.003114 ( 0.378619)
Memory
Calculating -------------------------------------
Active Record + Ruby code
175.136M memsize ( 0.000 retained)
2.422M objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Only Active Record 303.235k memsize ( 0.000 retained)
3.425k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Active Record + Ruby code v2
347.840k memsize ( 0.000 retained)
3.615k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Comparison:
Only Active Record: 303235 allocated
Active Record + Ruby code v2: 347840 allocated - 1.15x more
Active Record + Ruby code: 175135967 allocated - 577.56x more
Iterations per second
Warming up --------------------------------------
Active Record + Ruby code
1.000 i/100ms
Only Active Record 1.000 i/100ms
Active Record + Ruby code v2
1.000 i/100ms
Calculating -------------------------------------
Active Record + Ruby code
0.947 (± 0.0%) i/s - 5.000 in 5.294872s
Only Active Record 1.867 (± 0.0%) i/s - 10.000 in 5.357158s
Active Record + Ruby code v2
2.646 (± 0.0%) i/s - 14.000 in 5.292725s
Comparison:
Active Record + Ruby code v2: 2.6 i/s
Only Active Record: 1.9 i/s - 1.42x (± 0.00) slower
Active Record + Ruby code: 0.9 i/s - 2.79x (± 0.00) slower
Results of comparison
In conclusion, the third solution is the best, and the first solution is the least optimal. Why is the solution using pure Active Record the least optimal this time? Because the query we are doing makes joins of two tables, this has an optimization cost, as we can observe.
SELECT
query that usually joins multiple tables.SELECT
query that normally joins multiple tables.I’m using the best solution from the previous problem to compare with the SQL and Materialized views.
Elapsed Time
Rehearsal -------------------------------------------------------------
Active Record + Ruby code 0.096419 0.036263 0.132682 ( 0.598458)
SQL View 0.000640 0.000259 0.000899 ( 0.001701)
Materialized View 0.000671 0.000172 0.000843 ( 0.001408)
---------------------------------------------------- total: 0.134424sec
user system total real
Active Record + Ruby code 0.008866 0.001029 0.009895 ( 0.382414)
SQL View 0.000122 0.000003 0.000125 ( 0.000121)
Materialized View 0.000100 0.000000 0.000100 ( 0.000098)
Memory
Calculating -------------------------------------
Active Record + Ruby code
347.036k memsize ( 0.000 retained)
3.611k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
SQL View 1.992k memsize ( 0.000 retained)
29.000 objects ( 0.000 retained)
1.000 strings ( 0.000 retained)
Materialized View 1.992k memsize ( 0.000 retained)
29.000 objects ( 0.000 retained)
1.000 strings ( 0.000 retained)
Comparison:
SQL View: 1992 allocated
Materialized View: 1992 allocated - same
Active Record + Ruby code: 347036 allocated - 174.21x more
Iterations per second
Warming up --------------------------------------
Active Record + Ruby code
1.000 i/100ms
SQL View 10.036k i/100ms
Materialized View 10.049k i/100ms
Calculating -------------------------------------
Active Record + Ruby code
2.652 (± 0.0%) i/s - 14.000 in 5.284182s
SQL View 99.936k (± 1.1%) i/s - 501.800k in 5.021873s
Materialized View 100.465k (± 0.7%) i/s - 502.450k in 5.001495s
Comparison:
Materialized View: 100465.2 i/s
SQL View: 99935.8 i/s - same-ish: difference falls within error
Active Record + Ruby code: 2.7 i/s - 37887.16x (± 0.00) slower
Results of comparison
Remember
select
and pluck
methods from Active Record.size
or length
instead of count
if you don’t want to make an extra query.n + 1
queries with includes
method.I hope you enjoyed and learned something from this article. Thanks for reading! ❤️
]]>First, let’s define authentication as to how a user or a resource, in general, can be identified in your application, which means it is a way to recognize that a particular resource (user) is interacting with our application.
The authentication’s most known way is a form with a field for an email and another for a password. We can do it with only Rails, but we need to consider the security of storing this information because it is the user’s personal information.
There are many other ways of authentication, such as the one which is becoming more known last years: through social media apps. And, if our Rails application is API only, then we have other options like JWT
(JSON Web Tokens). But we will leave this as a topic for another article.
There are many ways in which a user can identify itself in our application. The more options the user has, the easier it will be to authenticate and use our application. In Rails, there are gems for each option we want to implement. This means a configuration for each one.
Auth0 gives us a solution to this problem. Auth0 offers us one way to make only one configuration in our application, and in its platform, we could choose what options (also known as identity providers) we want to give to our users. And, if we don’t know what options our users need to authenticate at the beginning, we could add them later without the necessity of changing our codebase.
You can find more info here: https://github.com/auth0/omniauth-auth0#what-is-auth0
We’re going to integrate Auth0 in an application. This demo only will have two features: login and logout.
Applications
menu, in the Applications
submenu, choose Regular Web Application
as the application type.Default Application
, we can change the application typo in theApplication Properties
section in the Application Type
field. Choose Regular Web Application
and save the changes.Token Endpoint Authentication Method
field is POST
as the selected option.Application URIs
section, we will change these fields with the following values:
http://localhost:3000/auth/auth0/callback
http://localhost:3000
Connections
tab and enable the Username-Password-Authentication
option.rails new
command to create a demo app. I’m using rails 7.0 and ruby 3.1.omniauth-auth0
gem, which helps us with the Auth0 configuration in the OAuth process, and the omniauth-rails_csrf_protection
gem, which helps us with the CSRF protection in the OAuth requests.AUTH0_CLIENT_ID
AUTH0_CLIENT_SECRET
AUTH0_DOMAIN
dotenv-rails
gem to handle the environment variables. With this gem, you can have a .env
file to save all the environment variables needed in your application.auth0.rb
in the config/initializers
folder:
touch config/initializers/auth0.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider(
:auth0,
ENV["AUTH0_CLIENT_ID"],
ENV["AUTH0_CLIENT_SECRET"],
ENV["AUTH0_DOMAIN"],
callback_path: "/auth/auth0/callback",
authorize_params: { scope: "openid profile" }
)
end
authorize_params
, we tell Auth0 what info of the user we want Auth0 to give us. Here you can see some additional params we can pass in this hash.routes.rb
:
scope "/auth" do
get "/auth0/callback", to: "auth0#callback"
get "failure", to: "auth0#failure"
end
touch app/controllers/auth0_controller.rb
callback
and failure
actions:
def callback
info = request.env['omniauth.auth']
session[:user_info] = info['extra']['raw_info']
redirect_to posts_path
end
def failure
@error_msg = request.params['message']
end
callback
action, we’re reading the information I receive from the request with the key omniauth.auth
. This value will be a hash with a key extra
which has a hash as a value and inside this hash, we have a key raw_info
.
posts
and used posts#index
)failure
action, we’re storing in a class variable the error message Auth0 sent, and then we can redirect the user or render a custom error page we want in our app.views/layouts/application.html.erb
<%= button_to "Login", "/auth/auth0", method: :post, data: { turbo: false } %>
data: { turbo: false }
because the Rails buttons use turbo
by default, and I’m deactivating in this particular case. You will have to make the same if your application use turbolinks
.ApplicationController
, we’ll add a helper called current_user
to can call the logged user.
helper_method :current_user
def current_user
session[:user_info]
end
current_user
doesn’t exist, that means if there’s no logged user:
<% if current_user %>
Hi <%= current_user["name"] %>
<% else %>
<%= button_to "Login", "/auth/auth0", method: :post, data: { turbo: false } %>
<% end %>
Auth0Controller
:
# routes.rb inside de /auth scope
get '/auth/logout' => 'auth0#logout'
# Auth0Controller
def logout
reset_session
redirect_to logout_url
end
reset_session
method, will delete all we have stored in the session def logout_url
request_params = { returnTo: post_url, client_id: ENV["AUTH0_CLIENT_ID"] }
URI::HTTPS.build(
host: ENV["AUTH0_DOMAIN"], path: "/v2/logout", query: to_query(request_params)
).to_s
end
def to_query(hash)
hash.map { |k, v| "#{k}=#{CGI.escape(v)}" unless v.nil? }.compact.join("&")
end
application/layout
:
<%= button_to "Logout", "auth/logout", method: :get, data: { turbo: false } %>
Let’s try our app and that’s it! You now can implement Auth0 in a Rails app.
]]>En este artículo, te contaré cómo fui solucionando cada uno de los problemas que tuve, iré desde los problemas más simples que uno puede ver en todos los proyectos en Rails hasta los más complejos.
La aplicación de ejemplo que usaré será un clon en su forma básica de Spotify o cualquier aplicación de música. Les explico cómo será las relaciones de nuestras tablas en nuestra base de datos.
Tengo canciones que le pertenece a un artista y esta canción le pertenece a un album, también tenemos una tabla de usuarios, que son los que pueden votar o poner un rating a una canción o a un album. La tabla ratings tiene una relación polimórfica hacia canciones y hacia albums.
NOTA: Una relación polimórfica es una forma en Rails de relacionar una tabla con más de una tabla a la vez sin necesidad de crear un foreign key con el nombre de la relación.
¿Cómo analizaremos si estas soluciones son más rápidas?
Hay muchas formas de analizar tu código, aquí lo haré de 3 formas:
Para tener estas 3 formas de medir nuestro código, usaremos el módulo Benchmark
que nos da ruby, y las gemas benchmark-ips
y benchmark-memory
Primera solución: Usando una combinación de métodos de ActiveRecord con métodos de Ruby:
Album.includes(:ratings).map { |album| album.ratings.length }.max
Segunda solución: Usando solo métodos de Active Record
Album.from(Album.select(
"albums.*", "COUNT(ratings.id) AS rating_count"
).joins(:ratings).group("albums.id")).maximum("rating_count").explain
Ambos devuelven el mismo resultado 20. ¿Cuál es la diferencia?
La primera parte de esta solución usa un método de ActiveRecord:
Album.includes(:ratings)
Esto ejecuta el siguiente código SQL
SELECT "albums".* FROM "albums"
SELECT "ratings".* FROM "ratings" WHERE "ratings"."ratingable_type" = "Album"
AND "ratings"."ratingable_id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ...)
(mientras más albums tengamos en nuestra base de datos, más grande será la lista de ratings que traeremos)
La segunda parte es
.map { |album| album.ratings.length }.max
Este map itera sobre la respuesta que nos devuelve el includes que hicimos. En cada album, llama a todos los rating y calculamos (con Ruby) cuántos ratings tiene cada album. Una vez que tenemos la lista con todos los totales, con el método max
de los Enumerators
en Ruby podemos calcular el elemento con el máximo valor.
Partiremos esta solución en dos partes, la primera que es la que está dentro del método from.
Album.select(
"albums.*", "COUNT(ratings.id) AS rating_count"
).joins(:ratings).group("albums.id")
Esto se ejecturá en el siguiente código SQL:
SELECT albums.*, COUNT(ratings.id) AS rating_count
FROM "albums" INNER JOIN "ratings"
ON "ratings"."votable_type" = "Album" AND "ratings"."votable_id" = "albums"."id"
GROUP BY "albums"."id"
Aquí estamos agregando la columna COUNT(ratings.id)
con el alias rating_count
a nuestra consulta para que por cada album cuente cuántos ratings asociados tiene.
La segunda parte es:
Album.from(...).maximum("rating_count")
Esto se ejecutarán en el siguiente código SQL:
SELECT MAX(rating_count) FROM (...) subquery
Toda la consulta que explicamos en la primera parte pasa a ser la subquery sobre la que vamos a hacer otra consulta, en este caso sacar el máximo valor de una de la columna rating_count
.
Este es el código que ejecutaremos:
Benchmark.bmbm do |x|
x.report("Active Record + Ruby code") { first_solution }
x.report("Only Active Record") { second_solution }
end
Nota: Estoy usando el método
bmbm
para que se ejecute dos veces los reportes, así ninguno de los métodos se verá afectado por ser el primero en ejecutarse.
Este es el resultado:
Rehearsal -------------------------------------------------------------
Active Record + Ruby code 5.554467 0.788396 6.342863 ( 7.404660)
Only Active Record 0.003204 0.001894 0.005098 ( 0.741973)
---------------------------------------------------- total: 6.347961sec
user system total real
Active Record + Ruby code 5.042479 0.540590 5.583069 ( 5.776371)
Only Active Record 0.001858 0.000419 0.002277 ( 0.532489)
Si analizamos el resultado hay una ligera diferencia de tiempo entre ambas soluciones, siendo la segunda solución la más rápida.
Este es el código que ejecutaremos:
Benchmark.memory do |x|
x.report("Active Record + Ruby code") { first_solution }
x.report("Only Active Record") { second_solution }
x.compare!
end
Este es el resultado:
Calculating -------------------------------------
Active Record + Ruby code
585.804M memsize ( 0.000 retained)
6.099M objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Only Active Record 53.270k memsize ( 0.000 retained)
511.000 objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Comparison:
Only Active Record: 53270 allocated
Active Record + Ruby code: 585803866 allocated - 10996.88x more
Si vemos este resultado, podemos decir que la primera solución está utilizando 1.54 veces más de memoria en comparación con la segunda solución.
Este es el código que ejecutaremos:
Benchmark.ips do |x|
x.report("Active Record + Ruby code") { first_solution }
x.report("Only Active Record") { second_solution }
x.compare!
end
Este es el resultado:
Warming up --------------------------------------
Active Record + Ruby code
1.000 i/100ms
Only Active Record 1.000 i/100ms
Calculating -------------------------------------
Active Record + Ruby code
0.159 (± 0.0%) i/s - 1.000 in 6.277377s
Only Active Record 12.768 (± 7.8%) i/s - 64.000 in 5.032453s
Comparison:
Only Active Record: 12.8 i/s
Active Record + Ruby code: 0.2 i/s - 80.15x (± 0.00) slower
Como vemos en los resultados, la primera solución es 1.44 veces más rápida que la segunda solución.
Entonces ¿por qué la segunda solución es la mejor?
Porque se hace una sola llamada en la base de datos y toda la operación se hace desde el lado de la BD. Esto hace que la busqueda se haga más rápida al estar en contacto directo con la data.
Esta vez tengo tres soluciones: Primera solución Usando métodos de Active Record y código de Ruby
ratings = Rating
.where(votable_type: "Song")
.group(:votable_id)
.average(:vote)
.sort_by { |r| -r[1] }
.take(n).to_h
songs = Song
.includes(:artists)
.find(ratings.keys)
ratings.map do |song_id, rating|
song = songs.find { |song| song.id == song_id }
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: rating.to_f
}
end
Segunda solución Usando una sola query con muchos métodos de Active Record
Song
.includes(:artists)
.joins(:ratings)
.select("songs.*, AVG(ratings.vote) as rating_avg")
.group("songs.id")
.order("rating_avg DESC")
.limit(n)
.map do |song|
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: song.rating_avg
}
end
Tercera solución Usando dos queries con métodos de Active Record y poco código de Ruby
ratings = Rating
.select("ratings.votable_id, AVG(ratings.vote) as rating_avg")
.where(votable_type: "Song")
.group(:votable_id)
.order("rating_avg DESC")
.limit(n)
Song
.includes(:artists)
.find(ratings.map(&:votable_id))
.map do |song|
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: ratings.find { |rating| rating.votable_id == song.id }.rating_avg
}
end
Para calcular las canciones con mayor rating, primero busco en la tabla ratings, los ratings que están relacionados con la tabla songs.
Recuerden que la tabla ratings tenía una relación polimórfica con songs, por eso es que debemos buscar aquellos ratings cuyo votable_type
es Song
, así sabremos cuáles ratings son solo de songs. Luego vamos a agruparlos por votable_id
(song.id) para poder sacar el average
(promedio) de todos los ratings.
Rating
.where(votable_type: "Song")
.group(:votable_id)
.average(:vote)
Esto nos devolverá un array de arrays de la siguiente forma: [[votable_id, vote_avg], [votable_id, vote_avg]]
, con todos los ids de las canciones y el promedio de los ratings de estas.
Lo que hará ahora es ordenarlos por el segundo elemento del array de forma descendente, tomar los n
primeros elementos y convertirlo en un hash.
.sort_by { |r| -r[1] }.take(n).to_h
En la segunda parte, obtenemos las canciones del hash que acabamos de generar, incluyendo los artistas asociados.
songs = Song.includes(:artists).find(ratings.keys)
Por último iteramos sobre el hash de ratings para generar la data requerida. En cada iteración buscamos el song al que le pertenece el rating y con eso armamos el hash a retornar como resultado.
ratings.map do |song_id, rating|
song = songs.find { |song| song.id == song_id }
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: rating.to_f
}
end
En esta solución intenté obtener el mismo resultado haciendo solo una consulta a la base de datos.
Primero haré una query sobre el joins
de las tablas songs y ratings, para poder tener la data de ambas tablas. Usaré el método includes(:artists)
para poder llamar a los artistas asociados a las canciones encontradas
Song.includes(:artists).joins(:ratings)
Sobre este joins
vamos a pedir el average
(por SQL gracias al método select
) agrupados por song.id
, luego ordenarlos por rating_avg
y por último llamando solo a los n
primeros records.
.select("songs.*, AVG(ratings.vote) as rating_avg")
.group("songs.id")
.order("rating_avg DESC")
.limit(n)
Finalmente iteramos sobre este resultado, para armar el hash que debemos retornar como resultado
.map do |song|
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: song.rating_avg
}
end
Hacemos algo similar a la primera solución, pero no usamos el método average
de ActiveRecord
, sino lo haremos por SQL usando el método select
. Hacemos el group
para que se el cálculo se haga por canción, filtramos la busqueda a solo las canciones con el método where
, y por último lo ordenamos y limitamos el resultado a los n
primeros
ratings = Rating
.select("ratings.votable_id, AVG(ratings.vote) as rating_avg")
.where(votable_type: "Song")
.group(:votable_id)
.order("rating_avg DESC")
.limit(n)
Luego buscamos los songs que de esos ratings que encontramos y le damos el formato que necesitamos para retornar el resultado esperado:
Song
.includes(:artists)
.find(ratings.map(&:votable_id))
.map do |song|
{
song: song.title,
artist: song.artists.map(&:name).join(", "),
rating_avg: ratings.find do |rating|
rating.votable_id == song.id
end.rating_avg
}
end
Tiempo de ejecución
Rehearsal ----------------------------------------------------------------
Active Record + Ruby code 0.787713 0.196014 0.983727 ( 1.645864)
Only Active Record 0.016700 0.002986 0.019686 ( 0.567646)
Active Record + Ruby code v2 0.024173 0.000953 0.025126 ( 0.403639)
------------------------------------------------------- total: 1.028539sec
user system total real
Active Record + Ruby code 0.595617 0.026087 0.621704 ( 1.016591)
Only Active Record 0.008948 0.000577 0.009525 ( 0.580103)
Active Record + Ruby code v2 0.002977 0.000137 0.003114 ( 0.378619)
Memoria
Calculating -------------------------------------
Active Record + Ruby code
175.136M memsize ( 0.000 retained)
2.422M objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Only Active Record 303.235k memsize ( 0.000 retained)
3.425k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Active Record + Ruby code v2
347.840k memsize ( 0.000 retained)
3.615k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
Comparison:
Only Active Record: 303235 allocated
Active Record + Ruby code v2: 347840 allocated - 1.15x more
Active Record + Ruby code: 175135967 allocated - 577.56x more
Iteraciones por segundo
Warming up --------------------------------------
Active Record + Ruby code
1.000 i/100ms
Only Active Record 1.000 i/100ms
Active Record + Ruby code v2
1.000 i/100ms
Calculating -------------------------------------
Active Record + Ruby code
0.947 (± 0.0%) i/s - 5.000 in 5.294872s
Only Active Record 1.867 (± 0.0%) i/s - 10.000 in 5.357158s
Active Record + Ruby code v2
2.646 (± 0.0%) i/s - 14.000 in 5.292725s
Comparison:
Active Record + Ruby code v2: 2.6 i/s
Only Active Record: 1.9 i/s - 1.42x (± 0.00) slower
Active Record + Ruby code: 0.9 i/s - 2.79x (± 0.00) slower
In conclusion, the third solution is the best, and the first solution is the least optimal. Why is the solution using pure Active Record the least optimal this time? Because the query we are doing makes joins of two tables, this has an optimization cost, as we can observe.
Sacando conclusiones, la tercera solución es la mejor y la primera solución es la menos óptima. ¿Por qué esta vez la solución que usa puro Active Record
es la menos óptima? Porque la consulta que estamos haciendo hace un joins
de dos tablas y esto tiene un costo de optimización como podemos observar.
Generar un reporte que tenga las top “N” canciones con el mayor rating y como párametros se pase aparte del número de canciones si queremos saber los artistas y albums de dichas canciones
Opciones de solución:
SQL Views
SELECT
que normalmente une múltiples tablas.Materialized views
SELECT
que normalmente une múltiples tablas como los SQL views.scenic
.Estoy usando la mejor solución del segundo problema para compararlo con SQL y Materialized views.
Tiempo de ejecución
Rehearsal -------------------------------------------------------------
Active Record + Ruby code 0.096419 0.036263 0.132682 ( 0.598458)
SQL View 0.000640 0.000259 0.000899 ( 0.001701)
Materialized View 0.000671 0.000172 0.000843 ( 0.001408)
---------------------------------------------------- total: 0.134424sec
user system total real
Active Record + Ruby code 0.008866 0.001029 0.009895 ( 0.382414)
SQL View 0.000122 0.000003 0.000125 ( 0.000121)
Materialized View 0.000100 0.000000 0.000100 ( 0.000098)
Memoria
Calculating -------------------------------------
Active Record + Ruby code
347.036k memsize ( 0.000 retained)
3.611k objects ( 0.000 retained)
50.000 strings ( 0.000 retained)
SQL View 1.992k memsize ( 0.000 retained)
29.000 objects ( 0.000 retained)
1.000 strings ( 0.000 retained)
Materialized View 1.992k memsize ( 0.000 retained)
29.000 objects ( 0.000 retained)
1.000 strings ( 0.000 retained)
Comparison:
SQL View: 1992 allocated
Materialized View: 1992 allocated - same
Active Record + Ruby code: 347036 allocated - 174.21x more
Iteraciones por segundo
Warming up --------------------------------------
Active Record + Ruby code
1.000 i/100ms
SQL View 10.036k i/100ms
Materialized View 10.049k i/100ms
Calculating -------------------------------------
Active Record + Ruby code
2.652 (± 0.0%) i/s - 14.000 in 5.284182s
SQL View 99.936k (± 1.1%) i/s - 501.800k in 5.021873s
Materialized View 100.465k (± 0.7%) i/s - 502.450k in 5.001495s
Comparison:
Materialized View: 100465.2 i/s
SQL View: 99935.8 i/s - same-ish: difference falls within error
Active Record + Ruby code: 2.7 i/s - 37887.16x (± 0.00) slower
Resultados de la comparación
En conclusión, la diferencia entre la solución optimizada del problema anterior y las nuevas soluciones usando SQL y Materialized views es muy notoria.
EXTRA
select
y pluck
de Active Record.size
o length
en lugar de count
si no quieres hacer un query adicional.n + 1
con el método includes
.Espero que hayas disfrutado y aprendido algo nuevo de este artículo. ¡Gracias por leerme! ❤️
]]>Primero definamos autenticación como la forma en que un usuario o un recurso en general se puede identificar en tu aplicación, es decir, una forma de reconocer que ese recurso en particular está interactuando con nuestra aplicación.
La forma más conocida de autenticación es a través de un formulario con un campo para un email y otro para una contraseña. Esta forma podemos hacerla con Rails puro, pero tenemos que tener en cuenta la seguridad con que guardamos ese información, porque es información personal del usuario
Existen muchas otras formas de autenticación, como la que se está haciendo más conocida en los últimos años: a través de redes sociales. Y si nuestra aplicación en Rails solo es una API, entonces tenemos otras opciones con los JWT (Json Web Tokens). Esto lo dejaremos como tema para otro artículo.
Cómo mencioné antes, hay muchas formas en las que un usuario se puede identificar en nuestra aplicación. Mientras más opciones le demos al usuario, será más fácil para este usuario autenticarse en nuestra aplicación. En rails existen gemas para cada opción que queramos implementar. Pero, esto significa una configuración para cada una.
Auth0 nos da una solución a esto. Nos dará una forma segura de hacer solo una configuración en nuestra aplicación y a través de su plataforma elegir qué opciones (identity providers) queremos darle a nuestros usuarios. Y si al inicio aún no sabemos qué opciones les podemos dar, las podríamos ir agregando con el tiempo sin necesidad de volver a cambiar nuestro código. Pueden encontrar un poco más de información sobre Auth0 por aquí: https://github.com/auth0/omniauth-auth0#what-is-auth0
Haremos una aplicación de prueba para integrar Auth0, esta aplicación solo tendrá los features de inicio y cierre de sesión.
omniauth-auth0
que nos ayuda con la configuración de Auth0 y omniauth-rails_csrf_protection
que nos ayuda con la protección CSRF en los requests de tipo OAuth. AUTH0_CLIENT_ID=
AUTH0_CLIENT_SECRET=
AUTH0_DOMAIN=
dotenv-rails
. Con esta gema puedes tener un archivo .env
el que guardes todas tus variables de entorno.config/initializers
con el nombre auth0.rb
touch config/initializers/auth0.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider(
:auth0,
ENV["AUTH0_CLIENT_ID"],
ENV["AUTH0_CLIENT_SECRET"],
ENV["AUTH0_DOMAIN"],
callback_path: "/auth/auth0/callback",
authorize_params: { scope: "openid profile" }
)
end
authorize_params
estamos diciendo a Auth0 qué información queremos que nos devuelva. Por aquí, podrán ver algunos parámetros adicionales que podemos pasarle a este hash.routes.rb
:
scope "/auth" do
get "/auth0/callback", to: "auth0#callback"
get "failure", to: "auth0#failure"
end
callback
y failure
:
def callback
info = request.env['omniauth.auth']
session[:user_info] = info['extra']['raw_info']
redirect_to posts_path
end
def failure
@error_msg = request.params['message']
end
omniauth.auth
. Este nos dará un hash con un key extra
que tiene un hash como valor y dentro de este hash tenemos otro hash en el key raw_info
.
views/layouts/application.html.erb
<%= button_to "Login", "/auth/auth0", method: :post, data: { turbo: false } %>
data: {turbo: false}
porque los botones en Rails por defecto usan turbo, y lo estoy desactivando en este caso en particular. Tendrás que hacer lo mismo si tu aplicación usa turbolinks
.ApplicationController
, agregaremos un helper llamado current_user
para poder llamar al usuario logueado.
helper_method :current_user
def current_user
session[:user_info]
end
button_tag
que usamos hace un momento, para que se muestre solo si no existe el current_user
, es decir si no hay usuario logueado:
<% if current_user %>
Hi <%= current_user["name"] %>
<% else %>
<%= button_to "Login", "/auth/auth0", method: :post, data: { turbo: false } %>
<% end %>
logout
en nuestras rutas y en Auth0Controller
:
# routes.rb inside de /auth scope
get '/auth/logout' => 'auth0#logout'
# Auth0Controller
def logout
reset_session
redirect_to logout_url
end
reset_session
, borrará todo lo que tenemos guardado en sesión def logout_url
request_params = { returnTo: post_url, client_id: ENV["AUTH0_CLIENT_ID"] }
URI::HTTPS.build(
host: ENV["AUTH0_DOMAIN"], path: "/v2/logout", query: to_query(request_params)
).to_s
end
def to_query(hash)
hash.map { |k, v| "#{k}=#{CGI.escape(v)}" unless v.nil? }.compact.join("&")
end
<%= button_to "Logout", "auth/logout", method: :get, data: { turbo: false } %>
Probemos cómo funciona nuestra app y listo! Ya puedes implementar Auth0 en una aplicación hecha en Rails.
]]>English version here
En fin, al terminar el refactor, separé la información en varios contextos, para así solo compartir la data necesaria con los componentes que lo necesitaban. Aunque suena como un refactor exitoso, no lo era, mis componentes se seguían actualizando cuando actualizaba un estado de un contexto del que no dependían. No tiene sentido, ¿verdad?
Para explicar mi problema, pondré un ejemplo. Tengo 3 componentes:
SessionForm
: Componente para agregar tu username. Si ya lo has ingresado, entonces te muestra un saludo y un botón para desloguearte (borrar el username). Si no lo has ingresado, te muestra un input para agregarlo.SessionCounterMessage
: Componente que muestra un mensaje con el username ingresado o un ‘You’ y el número que devuelva mi contador.CounterButtons
: Componente que tiene un contador. Son 2 botones que puedes sumar o restar al counter.Siguiendo mi primera solución, aquí crearía 2 contextos. Uno para el username (SessionContext
) y otro para el counter (CounterContext
). Entonces la dependencia de contextos de mis componentes quedaría así:
SessionForm
depende de SessionContext
CounterButtons
depende de CounterContext
SessionCounterMessage
depende de SessionContext
y CounterContext
Esta fue mi solución inicial:
function App() {
const [currentUser, setCurrentUser] = React.useState(null);
const [counter, setCounter] = React.useState(1);
return (
<SessionContext.Provider
value={React.useMemo(() => ({ currentUser, setCurrentUser }), [
currentUser,
setCurrentUser
])}
>
<CounterContext.Provider
value={React.useMemo(() => ({ counter, setCounter }), [
counter,
setCounter
])}
>
<SessionForm />
<SessionCounterMessage />
<CounterButtons />
</CounterContext.Provider>
</SessionContext.Provider>
);
}
Para que se den cuenta de mi error, agregué un console.log a mis componentes para que vean cuántas veces se renderizaba:
Allí pueden ver que cuando actualizo el counter
, se vuelve a renderizar el componente SessionForm
, a pesar de que no depende del contexto CounterContext
que es quien tiene a counter
como estado. Y que cuando actualizo el username
se vuelve a renderizar el componente CounterButtons
, que no depende del contexto SessionContext
, que tiene a username
como estado.
Ahora que vieron mi código, ¿encontraron el error? Bueno, yo no encontraba fallas en mi lógica. Si los había separado en diferentes contextos. Entonces ¿por qué se seguían renderizando todos los componentes?
Lo que hice fue pedir ayuda. Le pregunté a @sergiodxa que tiene más tiempo usando React y me dijo: Esto
const MyContext = React.useContext({});
function App() {
const [state, setState] = React.useState(false);
return (
<MyContext.Provider value={ { state, setState } }>
<MyCustomComponent />
</MyContext.Provider>
);
}
Es diferente a esto:
const MyContext = React.useContext({});
function MyContextProvider({ children }) {
const [state, setState] = React.useState(false);
return (
<MyContext.Provider value={ { state, setState } }>
{children}
</MyContext.Provider>
);
}
function App() {
return (
<MyContextProvider>
<MyCustomComponent />
</MyContextProvider>
);
}
No me explicó el por qué en ese momento, quizá estaba ocupado, no lo recuerdo. Pero me di cuenta que estaba renderizando mi componente en el mismo lugar que estaba creando mis estados. Así que cada vez que actualizaba el estado, volvió a renderizar mi componente padre, que a su vez renderizaba a todos sus hijos.
Con esto en mente, voy a cambiar el ejemplo que les dí al inicio, para comprobar que realmente funciona.
function SessionProvider({ children }) {
const [currentUser, setCurrentUser] = React.useState(null);
return (
<SessionContext.Provider
value={React.useMemo(() => ({ currentUser, setCurrentUser }), [
currentUser,
setCurrentUser,
])}
>
{children}
</SessionContext.Provider>
);
}
function CounterProvider({ children }) {
const [counter, setCounter] = React.useState(1);
return (
<CounterContext.Provider
value={React.useMemo(() => ({ counter, setCounter }), [
counter,
setCounter,
])}
>
{children}
</CounterContext.Provider>
);
}
function App() {
return (
<SessionProvider>
<CounterProvider>
<SessionForm />
<SessionCounterMessage />
<CounterButtons />
</CounterProvider>
</SessionProvider>
);
}
Aquí pueden ver los logs de las veces que se renderiza cada componente ¡Funciona! ¡No más renders innecesarios!
Puede parecer una cambio muy pequeño, incluso se puede llegar a pensar que el usuario no se va a dar cuenta. Pero los componentes que estaba refactorizando renderizaban audios y videos. Cada vez que hacían un cambio respecto a los audios, los videos se volvían a renderizar y se sentía como un bug en la aplicación.
Si llegaron hasta aquí, gracias por leerme. ❤️
]]>Versión en español aquí
I separated the data into many contexts, so I only share the necessary data with the component that needs them. So I stopped to pass a lot of props in every component. Even that sounds like a successful refactor, it wasn’t. My components keep updating when I updated an state of a context which they didn’t depend on. It doesn’t make sense, right?
To explain my problem, I’ll give you an example. I’ll have 3 components:
SessionForm
: Component to add a username. If you have already entered it, it shows a greeting and a button to log out (delete the username). If you haven’t entered it, it shows you an entry to add it.SessionCounterMessage
: Component that shows a message with the username entered or a You
and the number returned by a counter.CounterButtons
: Component with a counter and 2 buttons that allow you to add or subtract from the counter.Based on my first solution, I would create 2 contexts. One for the username (SessionContext
) and one for the counter ( CounterContext
). Then the dependency of contexts of my components would look like this:
SessionForm
depends on SessionContext
CounterButtons
depends on CounterContext
SessionCounterMessage
depends on SessionContext
and CounterContext
This was my initial solution:
function App() {
const [currentUser, setCurrentUser] = React.useState(null);
const [counter, setCounter] = React.useState(1);
return (
<SessionContext.Provider
value={React.useMemo(() => ({ currentUser, setCurrentUser }), [
currentUser,
setCurrentUser,
])}
>
<CounterContext.Provider
value={React.useMemo(() => ({ counter, setCounter }), [
counter,
setCounter,
])}
>
<SessionForm />
<SessionCounterMessage />
<CounterButtons />
</CounterContext.Provider>
</SessionContext.Provider>
);
}
I added a console.log to my components to make you aware of my error, I added a console.log to my components so that they see how many times it was rendered:
There you can see, when I update the counter, it re-renders the SessionForm
component. Even when it doesn’t depend on the CounterContext
context, which has counter
state.
And when I update the username, it re-renders the CounterButtons
component. Even when it doesn’t depend on the SessionContext
context, which has username
as a state.
Now you see my code, do you find my mistake? Well, I didn’t find any mistakes in my code if I had separated them into different contexts. Why did they keep re-render all the components?
What I did was ask for help. I asked @sergiodxa, who has been using React longer, and he said: This
const MyContext = React.useContext({});
function App() {
const [state, setState] = React.useState(false);
return (
<MyContext.Provider value=>
<MyCustomComponent />
</MyContext.Provider>
);
}
is different from this:
const MyContext = React.useContext({});
function MyContextProvider({ children }) {
const [state, setState] = React.useState(false);
return (
<MyContext.Provider value=>
{children}
</MyContext.Provider>
);
}
function App() {
return (
<MyContextProvider>
<MyCustomComponent />
</MyContextProvider>
);
}
He didn’t explain why at that time; maybe he was busy, I don’t remember. But I realized that I was rendering my component in the same place that I created my states. Every time I updated the state, it re-rendered my parent component, which re-render all its children.
With this in my mind, I’ll change my initial example to check it works.
function SessionProvider({ children }) {
const [currentUser, setCurrentUser] = React.useState(null);
return (
<SessionContext.Provider
value={React.useMemo(() => ({ currentUser, setCurrentUser }), [
currentUser,
setCurrentUser,
])}
>
{children}
</SessionContext.Provider>
);
}
function CounterProvider({ children }) {
const [counter, setCounter] = React.useState(1);
return (
<CounterContext.Provider
value={React.useMemo(() => ({ counter, setCounter }), [
counter,
setCounter,
])}
>
{children}
</CounterContext.Provider>
);
}
function App() {
return (
<SessionProvider>
<CounterProvider>
<SessionForm />
<SessionCounterMessage />
<CounterButtons />
</CounterProvider>
</SessionProvider>
);
}
Here you can see the logs when every component is rendered
It works! No more unnecessary renders!
It could look like a small change, and even you could think the user won’t notice this change. But the components I was refactoring rendered audios and videos. Every time I updated the audios, the videos would be re-rendered, and it looks like a bug in the app.
If you made it this far, thanks for reading. ❤️
]]>Cuando estaba en la universidad, sacaba buenas notas, enseñaba a mis compañeros y era responsable, por lo que los profesores me tenían más confianza. Y no tenía miedo de hablarle a los profesores, y decirle cuando algo me parecía injusto o preguntar cuando tenía dudas. No recuerdo la primera vez que mis compañeros empezaron a hablar mal de mí o estar en mi contra, pero les voy a contar de las dos veces que me hicieron sentir mal, y sola.
La primera fue cuando un profesor me encargó recolectar todos los trabajos, a pesar que no era la delegada del salón. El profesor, sentía más en confianza con que yo lo hiciera y yo creía que no tenía la culpa de ello. Una de las veces que salí del salón a hacer otras cosas entre clases, uno de mis compañeros reunió al resto para ponerse de acuerdo para no enviarme los trabajos y hacerme quedar mal frente al profesor. Así lo dijeron. Cerca de la mitad del salón, quizá por miedo, no aceptó la propuesta de este compañero, así que me enviaron los trabajos. Un amigo me contó sobre lo que querían hacer, como un chiste. Así que en su momento, no dije ni hice nada. Pero me quedé pensando que quizá sí fue mi culpa por aceptar estar a cargo.
La segunda fue algo peor que solo hablar delante de mis compañeros. Enviaron un mail a todos los de mi salón y otros alumnos de la facultad, hablando mal de mí y de una amiga, diciendo que nos regalaban la nota porque éramos amigas de los profesores. El correo era algo largo, pero ese era el resumen, no merecíamos el puesto que ocupábamos. Mis compañeros no me quisieron decir del mail, faltaba poco para mi cumpleaños. Sin embargo el mail llegó a oídos de los profesores, y directivos de mi facultad, que hablaron con mi salón un día que salí temprano de clases. Un amigo me hizo llegar el correo, porque se le escapó detalles de la reunión y se lo pedí. Al leer el mail, no sabía qué hacer, sólo empecé a llorar, no pude evitarlo. Mi mamá lo notó y me preguntó qué me pasaba, le señalé la computadora para que lo leyera, no tenía fuerza para hablar. Mi mamá, que siempre me dijo que termine la universidad cuando le decía que quería dejarle para estudiar por mi cuenta, en ese momento me preguntó si quería dejar la universidad o hacer un cambio de universidad o de carrera. No sabía qué hacer o qué decir, mi amigo estaba un poco preocupado así que le tuve que decir que estaba bien, pero me daba cólera lo que decían de mí. Pero en aquel entonces, solo pensaba qué hice mal, quizá no debí sacar buenas notas o hablar con los profesores o llamar la atención.
Olvidé comentar que muchas veces yo hablé con los profesores para que les dieran otra oportunidad, porque varios de mis compañeros estaban muy cerca de pasar y yo había visto su esfuerzo. Y uno (o más) de ellos creía todo esto de mí. Quizá por eso me dolió tanto, porque hice cosas para ayudarlos, y aún así algo había hecho mal.
Los días siguientes por suerte teníamos exámenes y podía decir que estaba ocupada haciendo los proyectos o estudiando, y así no tenía que verlos. Pero también se acercaba mi cumpleaños y mis amigos me preguntaban qué haría. Yo no quería nada, ni siquiera quería tener que pensar en eso. Días después, más amigos de otros años se enteraron también de lo que había pasado, y publicaron en los grupos de la facultad en redes sociales, defendiéndome, diciendo que yo me había ganado con esfuerzo el puesto en el que estaba y que ellos sabían lo que valía. Pero el problema es que yo empecé a dejar de pensar así. Por eso es que aún a veces tengo esta duda ¿será que merezco el lugar en el que estoy?
Siempre pensé que tuve mala suerte en tener esos compañeros y me tocó este tipo de situaciones, pero ahora que lo pensé mejor, muchos de mis amigos de otros años también se llevaban bien con los profesores, incluso mejor que yo, pero nunca les pasó lo que me pasó a mí. (Machismo tal vez?) Tuve mucha suerte de tener un grupo de apoyo en ese momento, para ayudarme a olvidar lo que había pasado o al menos a que no duela tanto.
Ahora, gracias a los talleres que he recibido y estoy facilitando, entendí que sí merezco lo que tengo hoy, y en ese momento también lo merecía, porque era fruto de mi esfuerzo y dedicación. Me hubiera gustado tanto haber llevado estos talleres antes y, así, no hubiera sufrido tanto en ese momento.
]]>Expo es una buena opción ya que tiene varios componentes adicionales construidos a partir de ReactNative para tener control de más APIs nativas y además te permite hacer build y deploy de tus aplicaciones para iOS, Android y web a la vez.
Para este tutorial, haré una aplicación de posts usando expo. Si quieres seguir usando la primera opción, te recomiendo hacer el setup inicial siguiendo la documentación oficial de ReactNative.
Requerimientos:
$ npm install -g expo-cli
Para este post tengo la versión 3.21.9
Para crear una aplicación, para este caso llamado MyPostApp
, solo debes correr:
$ expo init MyPostApp
Escoge empty template
, para empezar con una página en blanco.
Para ingresar al proyecto:
$ cd MyPostApp
Para iniciar el proyecto:
$ yarn start
Expo te dará un código QR con el que podrás abrir la aplicación desde tu celular.
Para empezar a editar tu aplicación, solo debes ir al archivo App.js
.
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.js to start working on your app!</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});
Llamamos a 3 componentes de ReactNative:
Guardemos nuestros posts en un array por el momento, ya que no tendremos una interacción con una base de datos.
const POSTS = [
{
id: 1,
body:
"In irure minim in pariatur nisi irure reprehenderit cupidatat. Consequat ea enim veniam Lorem id nulla proident aute.",
createdAt: new Date(2020, 3, 15),
author: "elizabeth",
},
{
id: 2,
body:
"Nisi laborum ea ad sit exercitation eu incididunt elit nostrud excepteur irure enim. Magna do aliqua officia officia dolore ad proident. Occaecat cillum sit veniam ea nostrud deserunt duis cupidatat laboris enim nostrud deserunt ex non.",
createdAt: new Date(2020, 5, 2),
author: "elizabeth",
},
{
id: 3,
body:
"Nulla Lorem Lorem occaecat laboris minim anim sit ea eiusmod. Sunt ea ex exercitation est veniam.",
createdAt: new Date(2020, 5, 25),
author: "emma",
},
];
Para renderizar listas con ReactNative, se usa FlatList estos props son las más básicos:
item
como prop, conteniendo cada uno de los ementos de la listaexport default function App() {
return (
<View style={styles.container}>
<FlatList
data={POSTS}
renderItem={({ item }) => <Text>{item.body}</Text>}
keyExtractor={(post) => Number(post.id)}
/>
</View>
);
}
Si estas en iOS, verás que tu aplicación se renderiza sin importarle el StatusBar, para evitar que tome este espacio se usa SafeAreaView.
Agreguemos un poco de estilos a nuestra lista de posts: Primero crearemos un componente para el card de cada post:
// components/PostCard.js
import React from "react";
import { View, Text, StyleSheet } from "react-native";
export default function PostCard({ post }) {
return (
<View style={styles.postContainer}>
<Text style={styles.postBody}>{post.body}</Text>
<Text style={styles.postAuthor}>{post.author}</Text>
</View>
);
}
const styles = StyleSheet.create({
postContainer: {
backgroundColor: "#dfefff",
marginHorizontal: 20,
marginVertical: 8,
borderWidth: 1,
borderColor: "#d1dcdf",
borderRadius: 5,
padding: 10,
},
postBody: {
fontSize: 16,
color: "#292944",
},
postAuthor: {
alignSelf: "flex-end",
color: "#6e6e7e",
fontSize: 14,
}
});
Actualizamos la página principal agregando un título y algunos estilos:
export default function App() {
return (
<SafeAreaView style={styles.container}>
<Text style={styles.heading}>Posts List</Text>
<FlatList
data={POSTS}
renderItem={({ item }) => <PostCard post={item} />}
keyExtractor={post => post.body}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#b4cefe",
alignItems: "center",
justifyContent: "center",
},
heading: {
color: "#292944",
fontSize: 24,
fontWeight: "600",
marginTop: 20,
marginBottom: 10,
}
});
Para crear un post, se abrirá un modal con un formulario. Para esto, usaremos tres componentes nuevos:
onPress
que recibe una función que se ejecutará cuando se presione el componente.visible
que se encargará de mostrar o no la vista que se encuentre dentro de este componente.Primero crearemos nuestro componente con el contenido del modal, el formulario para agregar un nuevo post. Este componente recibirá tres props, visible
que indicará si el modal se ve o no, setVisible
será una función que recibe el nuevo valor de visible, y setPosts
para editar la lista de posts inicial.
Para los valores del formulario, tendremos 2 estados, body y author. Para la UI, usaremos dos TextInput
y un TouchableOpacity
. En el prop onChangeText
de los TextInput
cambiaremos los valores de cada estado. Para el body, le agregaremos el prop multiline
con valor true
, así daremos la impresión de que el body contiene más que una linea de textos. El TouchableOpacity
lo usaremos para agregar un nuevo post a nuestra lista de posts y limpiar el valor de cada estado usado en el formulario.
// components/NewPostModal.js
import React from "react";
import {
Modal,
View,
TouchableOpacity,
Text,
TextInput,
StyleSheet,
} from "react-native";
export default function NewPostModal({ visible, setVisible, setPosts }) {
const [body, setBody] = React.useState("");
const [author, setAuthor] = React.useState("");
const createPost = () => {
setPosts((posts) => [
...posts,
{ id: Date.now(), body, author, createdAt: Date.now() },
]);
setBody("");
setAuthor("");
setVisible(false);
};
return (
<Modal visible={visible} transparent={true}>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<TouchableOpacity onPress={() => setVisible(false)}>
<View style={styles.closeButton}>
<Text>X</Text>
</View>
</TouchableOpacity>
<Text style={styles.title}>Nuevo post</Text>
<View style={styles.inputGroup}>
<TextInput
placeholder="Body"
style={[styles.textInput, { height: 35 }]}
value={body}
onChangeText={(text) => setBody(text)}
multiline={true}
/>
</View>
<View style={styles.inputGroup}>
<TextInput
placeholder="Author"
style={styles.textInput}
value={author}
onChangeText={(text) => setAuthor(text)}
/>
</View>
<TouchableOpacity
onPress={() => createPost()}
style={styles.createButton}
>
<Text>AGREGAR</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
modalContainer: {
backgroundColor: "rgba(0,0,0,0.8)",
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 25,
},
modalContent: {
backgroundColor: "#fff",
width: "100%",
paddingHorizontal: 15,
paddingVertical: 20,
},
closeButton: { alignSelf: "flex-end" },
title: {
fontSize: 20,
textAlign: "center",
marginBottom: 12,
},
inputGroup: { flexDirection: "row", marginVertical: 10 },
textInput: {
flex: 1,
borderBottomWidth: 1,
borderColor: "#b7b7b7",
height: 24,
paddingVertical: 4,
paddingHorizontal: 5,
},
createButton: {
alignSelf: "flex-end",
borderWidth: 1,
borderColor: "gray",
paddingHorizontal: 20,
paddingVertical: 5,
marginTop: 15,
},
});
Ahora agregaremos un botón en la página principal (App.js) que abrirá el nuevo modal que hemos creado. Para esto usaremos un TouchableOpacity
.
// App.js
export default function App() {
const [modalVisible, setModalVisible] = React.useState(false);
const [posts, setPosts] = React.useState(POSTS);
return (
<SafeAreaView style={styles.container}>
<Text style={styles.heading}>Posts List</Text>
<FlatList
data={posts}
renderItem={({ item }) => <PostCard post={item} />}
keyExtractor={(post) => post.body}
/>
<TouchableOpacity onPress={() => setModalVisible(true)} style={styles.button}>
<Text>Agregar post</Text>
</TouchableOpacity>
<NewPostModal visible={modalVisible} setVisible={setModalVisible} setPosts={setPosts} />
</SafeAreaView>
);
}
Con esto tenemos una app básica con ReactNative, con el que mostramos una lista de posts y podemos agregar un nuevo post en nuestra lista.
]]>The next doubt I had, after knowing the “best way” to build an API, is how am I going to build that API with all those rules? It looks so much work to do. Well… That’s not true! In Rails, it’s easy with a gem called jsonapi-resources
.
In this project, the frontend will be done with React. The last version of Rails (v.6.0.0), Rails comes with Webpacker integrated (gem to handle the integration Rails + Webpack). It will make easier for us to use React. 🙌
Consume the data from our API with React, it’s not hard. But, formatting the data to send to the API could be complex. There is another library to do this! Also, this library is going to help you to validate the form data. This library is Formik
.
Versions of the tools we are going to use:
To create a new project with rails, we need to use the rails new
command with the project name at the end.
We could also add some additional options. In this case, we will use --database=postgresql
to use PostgreSQL as our database, --skip-turbolinks
to avoid using turbolinks
because we will handle routing in the frontend, and --webpack=react
to make Rails generate the configuration for us to use React.js.
$ rails new my-app --database=postgresql --skip-turbolinks --webpack=react
Now, we’re going to add a model called Post with 2 attributes: title and body. title
is a string and body
is a text. In Rails, the model represents the database tables. We can generate it with the rails generate model
command followed by the model name with the attributes. The attributes should be separated by spaces and has the name and the type divided by :
, like title:string
. If we don’t specify the type of the attribute, Rails will default to the type string
.
The command generates a file with the model definition and a migration file that specifies the change to be made in the database, in this case, is the creation of the new table.
$ rails generate model Post title body:text
$ rails db:create
$ rails db:migrate
Note: We could also use
rails g
which is an alias ofrails generate
.
The rails db:create
command creates the database of the project and the rails db:migrate
command runs all the pending migrations since this is a new project it will run every migration.
We could add some seed data. To do it, we have to open the db/seeds.rb
file and add the following lines:
Post.create(title: "Post 1", body: "My first Post")
Post.create(title: "Post 2", body: "My second Post")
And to populate the database with our seed data, we need to run the command:
$ rails db:seed
In Rails projects, we should define the main route of the application this one is going to handle the path /
. Go to config/routes.rb
to define it and inside of the block Rails.application.routes.draw
, add:
root to: "home#index"
get "*path", to: "home#index", constraints: { format: "html" }
Note: The routes are defined as “home#index”, this means the controller which is going to control the behavior is
HomeController
and the specified action in the controller isindex
.
We have to create the HomeController. First, let’s create the home_controller.rb
file in app/controllers
folder. Inside, add the index
action:
class HomeController < ApplicationController
def index; end
end
Every action renders a view, in this case using HTML. We need to create the view in app/views/home
folder and name it index.html.erb
. In this file, we have to render the script to load our React app.
<%= javascript_pack_tag 'posts' %>
The helper javascript_pack_tag
will generate the following script tag:
<script src="/packs/js/posts-a447c92837fa3b701129.js"></script>
Note: The name of the pack is generated with a hash added at the end to let us cached it for a long time.
This script will load the pack posts.jsx
. We have to create that pack in the app/javascript/packs
folder:
import React from "react";
import ReactDOM from "react-dom";
import App from "components/App";
document.addEventListener("DOMContentLoaded", () => {
ReactDOM.render(
<App />,
document.body.appendChild(document.createElement("div"))
);
});
We are going to use @reach/router
to handle the routes in our React app. To install it, run:
$ yarn add @reach/router
Let’s create the component App.js
in app/javascript/components
folder. We will use this component to manage the routes.
import React from "react";
import { Router } from "@reach/router";
import PostList from "./PostList";
function App() {
return (
<Router>
<PostList path="/" />
</Router>
);
}
export default App;
Here we will create our first route /
, which is going to render the PostList
component.
Now we are going to create the component PostList.js
in app/javascript/components
folder.
import React from "react";
function PostList() {
return <div>Hello from my React App inside my Rails App!</div>;
}
export default PostList;
Inside we are going to render a div
to test our React App.
We need to install foreman
to run the React and Rails apps at the same time. We can install it with the command:
$ gem install foreman
We should create a Procfile.dev
file in the root of the project. Inside it, add:
web: bundle exec rails s
webpacker: ./bin/webpack-dev-server
To start the server, we need to run the command:
$ foreman start -f Procfile.dev
To create our API following the JSON:API specification, we are going to use the gem jsonapi-resources
. To use it, we have to add it to the Gemfile
and install it running bundle install
.
JSONAPI::Resources provides helper methods to generate correct routes. We’ll add the routes for API in config/routes.rb
, before get "*path"
:
namespace :api do
jsonapi_resources :posts
end
Note:
namespace :api
is going to generate routes with/api
before the route. Eg./api/posts
.
We’re going to create the ApiController
, to extend the controller from the ActionController::API
module of Rails, and also we’re going to include the JSONAPI::ActsAsResourceController
from JSONAPI::Resources.
class ApiController < ActionController::API
include JSONAPI::ActsAsResourceController
end
Now we need to create the PostsController
. We should create it inside a folder named api
because our routes config is going to search for an Api::PostsController
class.
class Api::PostsController < ApiController
end
jsonapi_resources :posts
require a PostResource
class defined. We have to create PostResource
in app/resources/api/post_resource.rb
.
class Api::PostResource < JSONAPI::Resource
attributes :title, :body
end
Here, we define the attributes and relationships we want to show as part of the resource.
To see how our response looks like, go to localhost:5000/api/posts
.
We will make the React app consume our API. First, let’s only read the data. Edit the PostList
component to fetch the list of posts.
import React, { useEffect, useState } from "react";
function PostList() {
const [posts, setPosts] = useState([]);
useEffect(() => {
const requestPosts = async () => {
const response = await fetch("/api/posts");
const { data } = await response.json();
setPosts(data);
};
requestPosts();
}, []);
return posts.map(post => <div>{post.attributes.title}</div>);
}
export default PostList;
Inside a useEffect
, we will do the fetch to /api/posts
and save the response in the state of the component.
Now, let’s create the form to add more posts. But first, we have to add formik
as a dependency in the React app.
$ yarn add formik
We are going to create a new component to show the form, let’s call it AddPost.js
. In this component, we are going to make a POST method to /api/posts
with the correct format of data to create a new post.
import React from "react";
import { navigate } from "@reach/router";
import { Formik, Field, Form } from "formik";
function AddPost() {
const handleSubmit = values => {
const requestPosts = async () => {
// We get the CSRF token generated by Rails to send it
// as a header in the request to create a new post.
// This is needed because with this token, Rails is going to
// recognize the request as a valid request
const csrfToken = document.querySelector("meta[name=csrf-token]").content;
const response = await fetch("/api/posts", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/vnd.api+json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({ data: values })
});
if (response.status === 201) {
navigate("/");
}
};
requestPosts();
};
return (
<div>
<h2>Add your post</h2>
<Formik
initialValues={\{
type: "posts",
attributes: {
title: "",
body: ""
}
}}
onSubmit={handleSubmit}
render={() => (
<Form>
<Field type="text" name="attributes.title" />
<Field type="text" name="attributes.body" />
<button type="submit">Create</button>
</Form>
)}
/>
</div>
);
}
export default AddPost;
Finally, we need to add the route /add
in our React app.
import React from "react";
import { Router } from "@reach/router";
import PostList from "./PostList";
import AddPost from "./AddPost";
function App() {
return (
<Router>
<PostList path="/" />
<AddPost path="/add" />
</Router>
);
}
export default App;
If we go to localhost:5000/add
, we will see the form. If we fill the fields and click on Submit, it will create a new post and will navigate automatically to localhost:5000/
, where we will see our new post as part of the list.
If we reload the page, the React app will fetch our post again with the new post we just created.
That’s how we can create an application with Rails + React, following the JSON:API spec.
I would love any feedback about the post or the libraries used here. ❤️
]]>