Una de las formas más comunes de almacenar datos en software es utilizando bases de datos relaciones. Aún y con el surgimiento de propuestas como las bases de datos NoSQL, el modelo relacional sigue estando presente porque es útil para la mayoría de los casos. ¿Qué es la integridad de los datos? Se refiere a que la información almacenada en una base de datos sea completa y correcta. Cuando insertamos, actualizamos o eliminamos información la integridad puede perderse. Existen distintos tipos de integridad: Integridad de dominio: Que los datos obligatorios estén presentes y sean del tipo esperado. Integridad de entidad: establece que la clave primaria de una tabla debe tener un valor único para cada fila de la tabla. La integridad referencial garantiza que la relación entre dos tablas permanezca sincronizada durante las operaciones y se asegura que los registros de las tablas relacionadas son válidos. Vamos a imaginar el caso de un banco que tiene las siguientes tablas: users, accounts deposits, withdrwals. Primero vamos a crear las migraciones que van a crear nuestras tablas mix phx.gen.schema User users email:string name:string mix phx.gen.schema Account accounts balance:decimal active:boolean user_id:references:users mix phx.gen.schema Deposit deposits amount:decimal account_id:references:accounts mix phx.gen.schema Withdrawal withdrawals amount:decimal account_id:references:accounts Por ahora tenemos las siguientes migraciones: defmodule MyApp.Repo.Migrations.CreateUsers do use Ecto.Migration def change do create table(:users) do add :email, :string add :name, :string timestamps() end end end defmodule MyApp.Repo.Migrations.CreateAccounts do use Ecto.Migration def change do create table(:accounts) do add :balance, :decimal add :active, :boolean, default: false, null: false add :user_id, references(:users, on_delete: :nothing) timestamps() end create index(:accounts, [:user_id]) end end defmodule MyApp.Repo.Migrations.CreateDeposits do use Ecto.Migration def change do create table(:deposits) do add :amount, :decimal add :account_id, references(:accounts, on_delete: :nothing) timestamps() end create index(:deposits, [:account_id]) end end defmodule MyApp.Repo.Migrations.CreateWithdrawals do use Ecto.Migration def change do create table(:withdrawals) do add :amount, :decimal add :account_id, references(:accounts, on_delete: :nothing) timestamps() end create index(:withdrawals, [:account_id]) end end Campos vacíos o nulos Como no queremos que un usuario pueda registrarse sin proporcionar su email y su nombre, forzaremos esto en la base de datos agregando la restricción null: false a la definición de las columnas. defmodule MyApp.Repo.Migrations.CreateUsers do use Ecto.Migration def change do create table(:users) do add :email, :string, null: false add :name, :string, null: false timestamps() end end end Otro lugar importante donde evitar la presencia de valores nulos es en las llaves foráneas. Además de incluir la misma opción en otros campos donde sea necesario: defmodule MyApp.Repo.Migrations.CreateAccounts do use Ecto.Migration def change do create table(:accounts) do add :balance, :decimal, null: false add :active, :boolean, default: false, null: false add :user_id, references(:users, on_delete: :nothing), null: false timestamps() end create index(:accounts, [:user_id]) end end defmodule MyApp.Repo.Migrations.CreateDeposits do use Ecto.Migration def change do create table(:deposits) do add :amount, :decimal, null: false add :account_id, references(:accounts, on_delete: :nothing), null: false timestamps() end create index(:deposits, [:account_id]) end end defmodule MyApp.Repo.Migrations.CreateWithdrawals do use Ecto.Migration def change do create table(:withdrawals) do add :amount, :decimal, null: false add :account_id, references(:accounts, on_delete: :nothing), null: false timestamps() end create index(:withdrawals, [:account_id]) end end Valores por defecto En algunos casos como los campos booleanos es buena práctica definir un valor por defecto. En el caso del campo active en la tabla accounts, los generadores de Phoenix agregaron dos restricciones al definir el campo como booleano. Además, considerando que tenemos un campo balance que almacena el balance de la cuenta, podríamos asumir que nuestro caso de uso es que al crear una cuenta se cree con un balance de 0 (no nulo). De esta forma tendremos que lidiar con menos validaciones si queremos realizar operaciones aritméticas con dicho valor. defmodule MyApp.Repo.Migrations.CreateAccounts do use Ecto.Migration def change do create table(:accounts) do add :balance, :decimal, default: 0, null: false add :active, :boolean, default: false, null: false add :user_id, references(:users, on_delete: :nothing), null: false timestamps() end create index(:accounts, [:user_id]) end end Campos únicos En la tabla users guardamos información como el email y el nombre del usuario. Como no queremos que haya 2 usuarios registrados con el mismo email, vamos a agregar esta restricción en nuestra tabla: defmodule MyApp.Repo.Migrations.CreateUsers do use Ecto.Migration def change do create table(:users) do add :email, :string, null: false add :name, :string, null: false timestamps() end create unique_index(:users, [:email]) end end La línea create unique_index(:users, [:email]) hace 2 cosas: Crea un índice en la columna email de la tabla users Este índice es único Esto hará que nuestra base de datos responda con un error si intentamos crear un registro con el mismo valor de email que un registro existente. Otra funcionalidad útil es que podemos crear esta misma restricción con múltiples columnas. Es decir, negar la creación de un registro donde la combinación de dos o más columnas sea igual al de otro registro. Solo hay que agregar las columnas: create unique_index(:users, [:email, :name]) Registros huérfanos En caso de que permitamos que los usuarios eliminen registros de la base de datos o esto suceda por algún proceso interno, podríamos perder integridad referencial. Por ejemplo, si eliminamos una cuenta de nuestra base de datos, puede que los depósitos y retiros de dicho usuario permanezcan, pero sin saber a que cuenta hacían referencia. Los generadores de Phoenix agregaron la opción on_delete: :nothing en todas las llaves foraneas, que es la opción por defecto. Pero tenemos varias opciones: :delete_all Elimina todos los registros relacionados. Alias de la opción CASCADE :nilify_all Establece la llave foránea en todos los registros relacionados en NULL. :restrict Genera un error si se intenta eliminar un registro padre que tiene registros relacionados. defmodule MyApp.Repo.Migrations.CreateAccounts do use Ecto.Migration def change do create table(:accounts) do add :balance, :decimal, default: 0, null: false add :active, :boolean, default: false, null: false add :user_id, references(:users, on_delete: :restrict), null: false timestamps() end create index(:accounts, [:user_id]) end end defmodule MyApp.Repo.Migrations.CreateDeposits do use Ecto.Migration def change do create table(:deposits) do add :amount, :decimal, null: false add :account_id, references(:accounts, on_delete: :restrict), null: false timestamps() end create index(:deposits, [:account_id]) end end defmodule MyApp.Repo.Migrations.CreateWithdrawals do use Ecto.Migration def change do create table(:withdrawals) do add :amount, :decimal, null: false add :account_id, references(:accounts, on_delete: :restrict), null: false timestamps() end create index(:withdrawals, [:account_id]) end end La mejor opción dependerá de nuestro caso de uso. Por ahora evitaremos su eliminación usando la opción on_delete: :restrict. Valores inválidos Por ejemplo, cuando se manejan valores monetarios, como en la tabla donde registramos los depósitos y la tabla de retiros. En el caso del campo balance de la tabla accounts un valor menor a 0 podríamos considerarlo inválido. Además, en el caso de los depósitos y retiros, no tiene mucho sentido que el monto de una de estas operaciones sea 0 y mucho menos menor a 0. defmodule MyApp.Repo.Migrations.CreateDeposits do use Ecto.Migration def change do create table(:deposits) do add :amount, :decimal, null: false add :account_id, references(:accounts, on_delete: :restrict), null: false timestamps() end create index(:deposits, [:account_id]) create constraint("deposits", "amount_must_be_positive", check: "amount 0", comment: "Deposit amount can't be 0 or negative") end end defmodule MyApp.Repo.Migrations.CreateWithdrawals do use Ecto.Migration def change do create table(:withdrawals) do add :amount, :decimal, null: false add :account_id, references(:accounts, on_delete: :restrict), null: false timestamps() end create index(:withdrawals, [:account_id]) create constraint("withdrawals", "amount_must_be_positive", check: "amount 0", comment: "Withdrawal amount can't be 0 or negative") end end Valores inválidos entre tablas Cuando tenemos una acción de retiro, asumimos que la cantidad que se retira se resta del balance de la cuenta de la cual se efectúa el retiro. Para evitar que se realice un retiro por un monto mayor al balance de la cuenta podemos agregar una verificación: defmodule MyApp.Repo.Migrations.CreateWithdrawals do use Ecto.Migration def up do create table(:withdrawals) do add :amount, :decimal, null: false add :account_id, references(:accounts, on_delete: :restrict), null: false timestamps() end create index(:withdrawals, [:account_id]) create constraint("withdrawals", "amount_must_be_positive", check: "amount 0", comment: "Withdrawal amount can't be 0 or negative") execute """ CREATE OR REPLACE FUNCTION validate_withdrawal_amount() RETURNS TRIGGER AS $$ BEGIN IF NEW.amount (SELECT balance FROM accounts WHERE id = NEW.account_id) THEN RAISE EXCEPTION 'Withdrawal amount is greater than account balance'; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; """ execute """ CREATE TRIGGER withdrawal_amount_validation BEFORE INSERT ON withdrawals FOR EACH ROW EXECUTE FUNCTION validate_withdrawal_amount(); """ end def down do execute """ DROP TRIGGER withdrawal_amount_validation IF EXISTS ON withdrawals; """ execute """ DROP FUNCTION validate_withdrawal_amount(); """ drop constraint("withdrawals", "amount_must_be_positive") drop index("withdrawals", [:account_id]) drop table("withdrawals") end end Dado que en estos ejemplos se está usando Postgres como base de datos, debemos crear una función que se encargue de realizar la verificación, además de un trigger que se encargue de llamar a dicha función cada vez que se realice un insert en la tabla withdrawals. Esto debido a que Postgres no soporta sub queries en los CONSTRAINT. Operaciones parciales En caso de que un usuario realice un retiro, hay 2 operaciones que deben realizarse: Crear el registro en la tabla withdrawals con la información del retiro Actualizar el balance en la tabla accounts restando el monto del retiro al balance de la cuenta Queremos evitar que una de las operaciones se complete correctamente, pero la otra no. Quedando la base de datos con un estado incorrecto. Por ejemplo, tener un registro de retiro, pero que este monto no sea restado del balance de la cuenta. Esto lo logramos con transacciones de la base de datos: MyRepo.transaction(fn - account = Accounts.get!(account_id) Withdrawals.create!(account, amount) Accounts.update_balance!(account, account.balance - amount) end) De esta forma nos aseguramos que ambas operaciones se ejecuten correctamente o de lo contrario se lanzara una excepción, realizando un rollback y ninguno de los cambios será confirmado. Así como estos escenarios hipotéticos, pueden surgir muchos más dependiendo de información que estemos almacenando en nuestra aplicación. También es importante distinguir los casos donde una restricción es más una regla de negocio que puede cambiar o ajustarse de manera más o menos frecuente. Se debe evaluar si la base de datos es el lugar correcto para dicha restricción o, por el contrario, nos restaría flexibilidad en el futuro y la capa de dominio sería un mejor lugar para manejarla. ¿Qué hay de agregar estas validaciones en los modelos/dominio? Seguro, pero como menciona David Bryant Copeland en su libro Sustainable Web Development with Ruby on Rails: Las validaciones son geniales para la experiencia de usuario, pero estas no proporcionan integridad de los datos.1 Referencias Avoiding Data Loss: Understanding the :on_delete Option in Elixir Migrations