Cómo versionar tu base de datos con "migraciones" en node.js y python

Cómo versionar tu base de datos con "migraciones" en node.js y python
Diagrama que ilustra el proceso de versionamiento de bases de datos, destacando cómo se puede realizar un upgrade para pasar de la versión v1.0 a v2.0 del esquema de la base de datos, o un downgrade para revertir los cambios y regresar a la versión anterior.

TLDR; Así como tu aplicativo o solución tiene versiones, la base de datos también debe tener versiones, las cuales pueden actualizarse a una versión mayor a través de "upgrades" o ser revertidas a versiones anteriores con "downgrades". Esto asegura que tu aplicativo pueda ser usado en diferentes etapas de su evolución de acuerdo a los requerimientos con integraciones o actualizaciones del cliente. En este post hablamos sobre qué son "migraciones" de una base de datos y construimos 3 ejemplos para aplicativos en Node.js con Sequelize, Node.js con TypeORM y Flask con SQLAlchemy y Alembic.

Enlaces a documentaciones oficiales:

También te dejo este enlace donde se encuentran los ejemplos de es Post en GitHub: Migraciones con Node.js y Python.

Entonces, ¿Qué son las "migraciones" en una base de datos?

De manera muy corta y sencilla, las migraciones en una base de datos es el versionamiento del esquema, o de las tablas o de la relación entre entidades (ERD -Entity Relationship Diagram-), de acuerdo con la evolución de tu aplicativo.

Ejemplo de un aplicativo web de correspondencia

En un ejemplo práctico: imagina que has desarrollado un aplicativo web para el seguimiento de envío de correspondencia física, como sobres y paquetes, que consta de un frontend, un backend y una base de datos.

Diagrama que muestra el flujo de interacciones entre distintas capas de un sistema de software. En este caso el usuario interactúa con la capa de cliente y la información viaja hasta la base de datos.

Este aplicativo tiene varias funciones y características, sin embargo, vamos a enfocarnos que un usuario final tiene la posibilidad de ver el próximo envío a recibir en una interfaz que le muestra desde donde viene, a donde se va a entregar y qué día será entregando, incluyendo una aproximación en la parte del día (mañana, tarde o noche).

Visualización de información de aplicativo ejemplo junto con los campos de información disponibles en la versión 1.0.

El aplicativo es perfecto, cumple con todas las solicitudes de los stake-holders o interesados y tus jefes están sumamente contentos con el trabajo realizado.

Los usuarios finales deciden invertir para añadirle una segunda serie de funcionalidades y características a tu aplicativo, entre las cuales, está la posibilidad de indicar el peso total del paquete próximo a recibir porque los clientes quieren saber si es algo muy grande que deben manejar en la entrega.

Bien, el cambio no es difícil, es solo añadir una columna nueva en la base de datos, actualizar el esquema o clase en la lógica del backend y habilitar un campo en la interfaz del frontend para introducir esta información.

Tu plan para llevar a cabo esta actualización, y la de las demás funcionalidades y características, es actualizar los repositorios, por lo cual ya tus aplicativos frontend y backend tienen la nueva lógica, y para tu base de datos, decides que entrarás al servidor de producción y harás este cambio manualmente con un ALTER TABLE pedidos ADD peso_kg DECIMAL (16, 4).

Como podrás notar, esto de entrar a los servidores en producción es un ¡no no no! Existen muchos riesgos y peligros, créeme. Por más experimentado que uno sea, siempre va a ocurrir el error al momento de alterar o modificar algo en una base de datos en producción (por desgracia, aprendí esto a las malas).

La "migración" de la base de datos en tu aplicativo de correspondencia

Para resolver este inconveniente de actualización del esquema de la base de datos, creamos o "generamos" migraciones que se encarguen de alterar, añadir o eliminar objetos de tu base de datos de acuerdo al requerimiento de las nuevas versiones.

Pero recuerda esto super importante: así como en tu control de versiones en GitHub, GitLab, BitBucket, Azure Devops, o el servicio que estés usando, debe existir tanto una forma de subir o mejorar tu versión de base de datos ("upgrade"), como una forma de bajar o degradar la versión ("downgrade").

Entonces vamos a crear 2 métodos o funciones que hagan exactamente esto mismo:

  1. La función de upgrade se encarga mejorar la base de datos desde la versión v1.0 a la v2.0.
  2. La función de downgrade se encarga de degradar la base de datos desde la versión v2.0 a la v.10.

Puede suceder el caso de que un error inesperado afecta producción y la mejor solución es revertir todos los cambios y retornar a una versión anterior. En nuestro ejemplo sería regresar a la versión v1.0, por lo tanto es importante tener la función downgrade disponible.

Así como hacemos git revert para revertir cambios en el repositorio y retornarlo a un estado anterior, lo mismo logramos con el método downgrade.

Representación de cómo se hace un cambio de versión usando el código fuente y un aplicativo para el manejo de versionamiento y el parecido con las migraciones de bases de datos.

Listo, de esta forma logras varios objetivos en tu aplicativo:

  • Manejar tu base de datos con un control de versiones.
  • Hacer actualizaciones de forma programada con cada salida o liberación de nueva versión de tu aplicativo.
  • Tener un camino o vía para retornar los cambios en cualquier punto de tu aplicativo.

Si, a pesar de que es mayor trabajo al momento de desarrollar tu aplicativo, es la mejor forma de tener un control y trazabilidad completa de tu esquema de base de datos.

Ahora, ¿Hay algunos ejemplos prácticos que podamos usar en Node.js o Python?

Si, para esta pregunta te voy a presentar 2 ejemplos en Node.js, uno usando la librería Sequelize y otro usando la librería TypeORM. El último ejemplo lo haremos en Python con la librería Alembic.

De nuevo, todo el código referente a estos ejemplos lo encuentras en Migraciones con Node.js y Python

Aplicativo en Node.js (JavaScript) usando Sequelize

En este primer ejemplo creamos una aplicación desde cero en Node.js usando las librerias dotenv, express, pg, pg-hstore y sequelize. Como dependencias de desarrollador usamos las librerias de nodemon y sequelize-cli.

Como vamos a usar dotenv, creamos un archivo .env el cual tendrá la variable DATABASE_CONNECTION_URI = postgres://usuario:contraseña@localhost:5432/migrations_sequelize apuntando a la base de datos.

Creamos el archivo .sequelizerc para indicar los paths hacia los distintos archivos de configuración. En este ejemplo indicamos donde se encuentra el archivo config.js.

En el archivo config.js indicamos el objeto "development" para nuestras variables en el ambiente de desarrollo e indicamos los valores de url y dialect para nuestra base de datos.

Creamos el directorio migrations e indicamos nuestras migraciones creando documentos con el formato <timestamp>-<nombre-migracion>.js. Y en este archivo definimos las funciones up y down a ser exportadas donde está la lógica de subida de versión y bajada de versión de la base de datos.

"use strict";

/** @type {import('sequelize-cli').Migration} */
module.exports = {
  up: (queryInterface, Sequelize) => {
    // lógica para transformación a nueva versión
  },
  down: (queryInterface, Sequelize) => {
    // lógica para revertir los cambios
  },
};

Ejemplo de métodos up y down de un archivo de migración en Sequelize.

Otra forma de crear este archivo es a través del uso de sequelize-cli, cuya herramienta nos permite usar el comando npx sequelize-cli model:generate --name MyModel el cual creará un archivo con el nombre <timestamp>-create-user.js en la carpeta migrations.

Nota: más información acerca de esta librería la encuentras en el portal de Sequelize Migrations.

Aplicativo en Node.js (TypeScript) usando TypeORM

En este segundo ejemplo creamos una aplicación desde cero en Node.js usando las librerias dotenv, express, pg, reflect-metadata y typeorm. Como dependencias de desarrollador usamos las librerias de @types/express, @types/node, nodemon, ts-node y typescript.

Con el uso de dotenv vamos a crear un archivo .env con la variable DATABASE_CONNECTION_URI = postgres://usuario:contraseña@localhost:5432/migrations_typeorm apuntando a la base de datos.

Creamos un archivo llamado data-source.ts donde usaremos la clase DataSource de typeorm. Adicionalmente importaremos todas las clases que representen un modelo de datos de las entidades del proyecto. Finalizamos el archivo exportando nuestro objeto AppDataSource con la configuración de nuestra fuente de datos.

Al ser un proyecto en TypeScript, también se debe configurar el archivo tsconfig.json, el cuál dependerá de cada proyecto o de tus preferencias en este framework.

Y por último creamos nuestros archivos de migraciones, los cuales llevan un formato parecido al de Sequelize: <timestamp>-<NombreMigracion>.ts (nótese el uso del formato Pascal Case).

import { MigrationInterface, QueryRunner } from "typeorm"

export class PostRefactoringTIMESTAMP implements MigrationInterface {
    async up(queryRunner: QueryRunner): Promise<void> {
      // // lógica para transformación a nueva versión
    }

    async down(queryRunner: QueryRunner): Promise<void> {
      // lógica para revertir los cambios
    }
}

Ejemplo de métodos up y down de un archivo de migración en TypeORM.

Otra forma de crear este archivo es a través del cli usando el comando typeorm migration:create ./path-to-migrations-dir/PostRefactoring, el cual creará el archivo con el nombre de migración PostRefactoring usando el timestamp actual, para que puedas modificarlo a tu gusto.

Nota: más información acerca de esta librería la encuentras en el portal de TypeORM Migrations.

Aplicativo en Flask (Python) usando SQLAlchemy y Alembic

Y para nuestro último ejemplo creamos una aplicación desde cero en Flask usando las librerías Flask, SQLAlchemy, Flask-SQLAlchemy, Alembic, Flask-Migrate y psycopg2-binary.

En este aplicativo también podemos usar librerías para el uso de variables de entorno como lo es python-dotenv, sin embargo, en nuestro ejemplo nos saltamos esta parte. De todas maneras recuerda mantener la seguridad de tu aplicativo usando variables de entorno y secretos para las credenciales de los aplicativos. También para el caso de Flask vamos a crear un archivo config.py donde exportamos la clase Config con las variables SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:password@localhost:5432/migrations_alembic' y SQLALCHEMY_TRACK_MODIFICATIONS = False.

El uso de Alembic para las migraciones tiene una lógica distinta a las de Sequelize y TypeORM: las migraciones no se crean a mano sino a través de cli (aunque éstas últimas también tienen la tienen opción, en Alembic es el método preferido). Entonces, en este caso usaremos el comando flask db init para inicializar nuestro sistema de migraciones, para luego usar el comando flask db migrate -m "Initial migration." y por último el comando flask db upgrade. En estos 3 pasos hemos inicializado la carpeta de migraciones (en el archivo app.py le hemos indicado al objeto migrate que usaremos el nombre de migraciones como carpeta).

En Alembic las migraciones van en la carpeta versions y no van enumeradas de menor a mayor a través del uso de timestamp o numeración con en las otras librerias, sino que se asigna una cadena hexadecimal de 12 carácteres o un "12 digit hex code", y el orden de las versiones se indica a través de las variables revision y down_revision dentro de cada archivo (pudiendo complicar a simple vista el orden de las migraciones para nosotros los humanos).

Dentro de cada archivo se encuentra el código a ejecutar para subir o bajar de versión.

"""Initial migration.

Revision ID: de3dad1e7bad
Revises: 
Create Date: 2024-06-26 12:26:49.288399

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'de3dad1e7bad'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
    # lógica para transformación a nueva versión


def downgrade():
    # lógica para revertir los cambios

Ejemplo de métodos upgrade y downgrade de un archivo de migración en Sequelize.

Y listo, usamos el comando flask db upgrade para actualizar nuestra base de datos para tener nuestro proyecto en Flask al día.

Nota: más información acerca de esta librería la encuentras en el portal de Flask-Migrate usando Alembic.

Conclusiones

Las migraciones de bases de datos son esenciales para mantener la integridad y la funcionalidad de una aplicación en crecimiento. Así como el código de una aplicación evoluciona, también lo debe hacer la estructura de su base de datos. Al implementar un enfoque estructurado para las migraciones, nos podemos asegurar que las actualizaciones y mejoras se realicen de manera controlada y reproducible, minimizando el riesgo de errores en producción. Esto es especialmente crucial en entornos donde la precisión y la consistencia son fundamentales, como en sistemas que manejan grandes volúmenes de datos o que tienen requisitos regulatorios estrictos.

Incorporar tanto las funciones de "upgrade" como de "downgrade" en el proceso de migración no solo permite mejorar la base de datos, sino también revertir cambios en caso de errores, así como también el uso de herramientas como Sequelize, TypeORM y Alembic hace que la creación y gestión de migraciones sea más accesible y menos propensa a fallas.

Y finalmente, adoptar un enfoque proactivo en la gestión de migraciones refuerza el compromiso de un equipo de desarrollo con la calidad y la estabilidad del software. A medida que las aplicaciones crecen en complejidad y alcance, la capacidad de gestionar cambios en la base de datos de manera eficaz se convierte en un diferenciador clave.