Rails: cómo establecer una restricción de índice intercambiable única

Establecer la validación de unicidad en rieles es algo que terminará haciendo con bastante frecuencia. Quizás, incluso ya los agregó a la mayoría de sus aplicaciones. Sin embargo, esta validación solo brinda una buena interfaz de usuario y una buena experiencia. Informa al usuario de los errores que impiden que los datos se conserven en la base de datos.

Por qué la validación de unicidad no es suficiente

Incluso con la validación de unicidad, los datos no deseados a veces se guardan en la base de datos. Para mayor claridad, echemos un vistazo a un modelo de usuario que se muestra a continuación:

class User validates :username, presence: true, uniqueness: true end 

Para validar la columna de nombre de usuario, rails consulta la base de datos usando SELECT para ver si el nombre de usuario ya existe. Si es así, imprime "El nombre de usuario ya existe". Si no es así, ejecuta una consulta INSERT para conservar el nuevo nombre de usuario en la base de datos.

Cuando dos usuarios están ejecutando el mismo proceso al mismo tiempo, la base de datos a veces puede guardar los datos independientemente de la restricción de validación y ahí es donde entran las restricciones de la base de datos (índice único).

Si el usuario A y el usuario B intentan mantener el mismo nombre de usuario en la base de datos al mismo tiempo, rails ejecuta la consulta SELECT, si el nombre de usuario ya existe, informa a ambos usuarios. Sin embargo, si el nombre de usuario no existe en la base de datos, ejecuta la consulta INSERT para ambos usuarios simultáneamente como se muestra en la imagen a continuación.

Ahora que sabe por qué es importante el índice único de la base de datos (restricción de la base de datos), veamos cómo configurarlo. Es bastante fácil establecer índices únicos de la base de datos para cualquier columna o conjunto de columnas en rieles. Sin embargo, algunas restricciones de la base de datos en rieles pueden ser complicadas.

Un vistazo rápido a la configuración de un índice único para una o más columnas

Esto es tan simple como realizar una migración. Supongamos que tenemos una tabla de usuarios con el nombre de usuario de la columna y queremos asegurarnos de que cada usuario tenga un nombre de usuario único. Simplemente crea una migración e ingresa el siguiente código:

add_index :users, :username, unique: true 

Luego ejecuta la migración y eso es todo. La base de datos ahora asegura que no se guarden nombres de usuario similares en la tabla.

Para múltiples columnas asociadas, supongamos que tenemos una tabla de solicitudes con las columnas sender_id y receiver_id. De manera similar, simplemente crea una migración e ingresa el siguiente código:

add_index :requests, [:sender_id, :receiver_id], unique: true 

¿Y eso es? Oh, no tan rápido.

El problema con la migración de múltiples columnas anterior

El problema es que los identificadores, en este caso, son intercambiables. Esto significa que si tiene un sender_id de 1 y un receiver_id de 2, la tabla de solicitudes aún puede guardar un sender_id de 2 y un receptor_id de 1, aunque ya tengan una solicitud pendiente.

Este problema ocurre a menudo en una asociación autorreferencial. Esto significa que tanto el remitente como el receptor son usuarios y se hace referencia a sender_id o receiver_id desde el user_id. Un usuario con user_id (sender_id) de 1 envía una solicitud a un usuario con user_id (receiver_id) de 2.

Si el receptor envía otra solicitud nuevamente, y le permitimos guardar en la base de datos, entonces tenemos dos solicitudes similares de los mismos dos usuarios (remitente y receptor || receptor y remitente) en la tabla de solicitudes.

Esto se ilustra en la siguiente imagen:

La solución común

Este problema a menudo se soluciona con el pseudocódigo siguiente:

def force_record_conflict # 1. Return if there is an already existing request from the sender to receiver # 2. If not then swap the sender and receiver end 

El problema con esta solución es que el receptor_id y el sender_id se intercambian cada vez antes de guardar en la base de datos. Por lo tanto, la columna receiver_id tendrá que guardar sender_id y viceversa.

Por ejemplo, si un usuario con sender_id de 1 envía una solicitud a un usuario con receiver_id de 2, la tabla de solicitudes será como se muestra a continuación:

Esto puede no parecer un problema, pero es mejor si sus columnas están guardando los datos exactos que desea que guarden. Esto tiene numerosas ventajas. Por ejemplo, si necesita enviar una notificación al receptor a través del receptor_id, entonces consultará en la base de datos la identificación exacta de la columna receptor_id. Esto ya se volvió más confuso en el momento en que comienza a cambiar los datos guardados en su tabla de solicitudes.

La solución adecuada

This problem can be entirely resolved by talking to the database directly. In this case, I’ll explain using PostgreSQL. When running the migration, you must ensure that the unique constraint checks for both (1,2) and (2,1) in the request table before saving.

You can do that by running a migration with the code below:

class AddInterchangableUniqueIndexToRequests < ActiveRecord::Migration[5.2] def change reversible do |dir| dir.up do connection.execute(%q( create unique index index_requests_on_interchangable_sender_id_and_receiver_id on requests(greatest(sender_id,receiver_id), least(sender_id,receiver_id)); create unique index index_requests_on_interchangable_receiver_id_and_sender_id on requests(least(sender_id,receiver_id), greatest(sender_id,receiver_id)); )) end dir.down do connection.execute(%q( drop index index_requests_on_interchangable_sender_id_and_receiver_id; drop index index_requests_on_interchangable_receiver_id_and_sender_id; )) end end end end 

Code explanation

After creating the migration file, the reversible is to ensure that we can revert our database whenever we must. The dir.up is the code to run when we migrate our database and dir.down will run when we migrate down or revert our database.

connection.execute(%q(...)) is to tell rails that our code is PostgreSQL. This helps rails to run our code as PostgreSQL.

Since our “ids” are integers, before saving into the database, we check if the greatest and least (2 and 1) are already in the database using the code below:

requests(greatest(sender_id,receiver_id), least(sender_id,receiver_id)) 

Then we also check if the least and greatest (1 and 2) are in the database using:

requests(least(sender_id,receiver_id), greatest(sender_id,receiver_id)) 

The request table will then be exactly how we intend as shown in the image below:

And that’s it. Happy coding!

References:

Edgeguides | Thoughtbot