Cuando trabajamos en un modelo en Rails y necesitamos agregar una validación uniqueness con case insensitive, por lo general seguimos estos pasos:
- Creamos el índice
unique
en la base de datos:
# db/schema.rb
...
t.index ["email_address"], name: "index_users_on_email_address", unique: true
2. Agregamos la validación uniqueness
al modelo con la opción case_sensitive
en false
:
# app/models/user.rb
class User < ApplicationRecord
...
validates :email_address, uniqueness: { case_sensitive: false }
end
Eso parece ser la solución más adecuada, ¿verdad?. Bueno, no del todo 😬. Hay algunas cosas que hacen que esta solución esté incompleta y sea ineficiente.
En primer lugar, el índice unique
en la base de datos “no es case-sensitive”. Esto significa que si quitamos la validación uniqueness
del modelo o no usamos ActiveRecord para crear el usuario, podríamos terminar con dos registros con la misma dirección de correo electrónico, como “dummy@mydomain.com” y “duMMy@mydomain.com”.
Pero, incluso si siempre utilizamos ActiveRecord y confiamos en la validación uniqueness
del modelo, todavía no estamos aprovechando al máximo el índice, lo que podría impactar en el rendimiento.
Veamos qué sucede al validar un usuario con ActiveRecord con y sin la opción case_sensitive
:
# Sin la opción `case_sensitive: false`
> User.new(email_address: 'duMMy@mydomain.com').valid?
(0.322ms) ActiveRecord -- User Exists? -- { :sql => "SELECT 1 AS one FROM \"users\" WHERE \"users\".\"email_address\" = $1 LIMIT $2", :binds => { :email_address => "duMMy@mydomain.com", :limit => 1 }, :allocations => 6, :cached => nil }
# Con la opción `case_sensitive: false`
> User.new(email_address: 'duMMy@mydomain.com').valid?
(1.424ms) ActiveRecord -- User Exists? -- { :sql => "SELECT 1 AS one FROM \"users\" WHERE LOWER(\"users\".\"email_address\") = LOWER($1) LIMIT $2", :binds => { :email_address => "duMMy@mydomain.com", :limit => 1 }, :allocations => 6, :cached => nil }
Si observamos las consultas SQL, podemos ver que en el segundo caso ActiveRecord agrega LOWER()
para realizar la comparación, y ésta toma mucho más tiempo que la primera consulta. Es decir, sin el uso de LOWER()
, la consulta toma alrededor de ~0.322ms, mientras que con LOWER()
toma ~1.424ms. Y estos resultados son con una base de datos pequeña. Imaginate la diferencia si tenemos un millón de registros!
Si profundizamos un poco más, un análisis con la instrucción EXPLAIN
de ambas consultas revela lo siguiente:
- Consulta sin
LOWER()
:
development=# EXPLAIN SELECT 1 AS one FROM "users" WHERE "users"."email_address" = 'duMMy@mydomain.com' LIMIT 1;
QUERY PLAN
-----------------------------------------------------------------------------------------------------
Limit (cost=0.27..8.29 rows=1 width=4)
-> Index Only Scan using index_users_on_email_address on users (cost=0.27..8.29 rows=1 width=4)
Index Cond: (email_address = 'dummy@mydomain.com'::text)
(3 rows)
- Consulta con
LOWER()
:
development=# EXPLAIN SELECT 1 AS one FROM "users" WHERE LOWER("users"."email_address") = LOWER('duMMy@mydomain.com') LIMIT 1;
QUERY PLAN
--------------------------------------------------------------------------------
Limit (cost=0.00..4.79 rows=1 width=4)
-> Seq Scan on users (cost=0.00..9.57 rows=2 width=4)
Filter: (lower((email_address)::text) = 'dummy@mydomain.com'::text)
(3 rows)
En el primer caso, la consulta utiliza el índice, mientras que en el segundo, realiza un escaneo secuencial 😖.
Para resolver este problema y optimizar el rendimiento, necesitamos agregar un índice que incluya LOWER()
. Esto se puede lograr mediante una migración similar a esta:
add_index(
:users,
'lower(email_address)',
name: "index_users_on_lower_email_address_unique",
unique: true
)
Con esta modificación, podemos asegurarnos de que las consultas realizadas por ActiveRecord utilicen este nuevo índice, mejorando significativamente el rendimiento.
En resumen, aunque la solución inicial puede parecer adecuada, es importante tener en cuenta las limitaciones y optimizaciones posibles al trabajar con validaciones uniqueness y case insensitive en Rails. En este caso, agregar un índice adecuado nos ayudó a mejorar significativamente el rendimiento al realizar este tipo de consultas.