Ejecuta migraciones y seeders con Expo SQLite y React Native

En el desarrollo móvil actual, construir aplicaciones que persistan información en el dispositivo es una necesidad. Para ello, podemos utilizar una base de datos local como SQLite.
Sin embargo, cuando se trata de ir haciendo cambios a la estructura de la base de datos, es necesario tener algo que nos permita hacerlo de forma sencilla y segura.
En este artículo, te mostraré como utilizar migraciones y seeders dentro de tu proyecto de React Native. Aunque hay herramientas como Drizzle ORM o Knex.js que son mencionadas en la documentación, no son las que vamos a utilizar. Si no que nos concentraremos en hacerlo sin estas dependencias.
¿Qué son las migraciones y los seeders?
Las migraciones son archivos que contienen instrucciones para modificar la estructura de una base de datos. Estas instrucciones pueden ser para crear, modificar o eliminar tablas, columnas, índices, etc.
Los ORMs (Object-Relational Mapping) utilizan las migraciones para mantener un control de versiones de la base de datos. Esto permite que varios desarrolladores trabajen en el mismo proyecto sin tener que coordinar manualmente los cambios en la estructura de la base de datos.
Las migraciones son útiles porque:
- Permiten versionar los cambios en la base de datos
- Facilitan el trabajo en equipo
- Permiten revertir cambios si algo sale mal
- Mantienen un historial de los cambios realizados
- Permiten recrear la base de datos desde cero en cualquier momento
Los seeders, por otro lado, son archivos que contienen datos iniciales para la base de datos. Estos datos son útiles para pruebas, desarrollo y demostraciones.
Instalando Expo SQLite y AsyncStorage
Para empezar, vamos a instalar las dependencias necesarias para poder utilizar SQLite en nuestro proyecto. Tomaremos como ejemplo un proyecto de React Native con Expo. El proceso puede cambiar un poco si estamos utilizando un proyecto Bare de React Native, debido a los cambios que pueden haber en la implementación de la librería.
Para instalar las dependencias, podemos utilizar el siguiente comando:
expo install expo-sqlite @react-native-async-storage/async-storage
También, puedes optar por utilizar MMKV en lugar de AsyncStorage. Esto es una alternativa más ligera y rápida para el almacenamiento de datos, considerada 10x más rápida que AsyncStorage.
Una vez que tengamos la librería instalada, podemos empezar a utilizarla en nuestro proyecto. Lo primero que haremos será dirigirnos a nuestro archivo App.tsx
y configurar la carga de la base de datos:
import { Suspense } from 'react';
import { ActivityIndicator } from 'react-native';
import { SQLiteDatabase, SQLiteProvider } from 'expo-sqlite';
const SQLITE_DATABASE_NAME = 'my-database';
export default function App() {
const onInitSQLite = async (db: SQLiteDatabase) => {
// Aquí podemos colocar código que busquemos ejecutar al inicializar
// nuestra base de datos.
};
return (
<Suspense fallback={<ActivityIndicator size="large" />}>
<SQLiteProvider
databaseName={SQLITE_DATABASE_NAME}
options={{ enableChangeListener: true }}
onInit={onInitSQLite}
useSuspense
>
{/* Your application code */}
</SQLiteProvider>
</Suspense>
);
}
Preparando el gestor de ejecución
Ahora, vamos a crear un gestor de ejecución que nos permitirá ejecutar cada uno de los archivos (runners) que contendrán los comandos de las migraciones y seeders.
Para ello, vamos a crear un archivo llamado manager.ts
que contendrá la lógica para ejecutar cada uno de los archivos. Para este ejemplo, lo vamos a generar en la carpeta lib/database/runners/manager.ts
.
import { SQLiteDatabase } from 'expo-sqlite';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface SQLRunner {
key: string;
run: (db: SQLiteDatabase) => Promise<void>;
}
const runners: SQLRunner[] = [
// Add runners here
];
export const execRunners = async (db: SQLiteDatabase) => {
for (const runner of runners) {
try {
const isSeeded = await AsyncStorage.getItem(runner.key);
if (!isSeeded) {
await runner.run(db);
await AsyncStorage.setItem(runner.key, 'true');
console.log(`Runner ${runner.key} completed successfully`);
}
} catch (error) {
console.error(`Error running runner ${runner.key}:`, error);
throw error;
}
}
};
En este bloque podremos ver que ejecuta cada uno de los runners y a través de AsyncStorage
, nos aseguramos de que no se ejecute más de una vez si es que este ya ha sido previamente ejecutado.
/* Verificamos si ya ha sido ejecutado */
const isSeeded = await AsyncStorage.getItem(runner.key);
if (!isSeeded) {
await runner.run(db);
/* Almacenamos la clave del archivo ejecutado */
await AsyncStorage.setItem(runner.key, 'true');
console.log(`Runner ${runner.key} completed successfully`);
}
Creando nuestro primer archivo de ejecución
Para empezar, vamos a crear un archivo de ejecución que nos permitirá crear las tablas de nuestra base de datos. Para este ejemplo, lo vamos a generar en la carpeta lib/database/runners/initial-database.migration.ts
.
import { SQLiteDatabase } from 'expo-sqlite';
import { SQLRunner } from './manager';
export const createInitialSQLRunner: SQLRunner = {
key: '@create_initial_database_runner',
run: async (db: SQLiteDatabase) => {
try {
await db.runAsync(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
content TEXT,
created_at TEXT,
updated_at TEXT
)
`);
await db.runAsync(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT
)
`);
await db.runAsync(`
CREATE TABLE IF NOT EXISTS notes_tags (
note_id INTEGER,
tag_id INTEGER,
PRIMARY KEY (note_id, tag_id)
)
`);
} catch (error) {
console.error('Error processing @create_initial_database_runner runner:', error);
throw error;
}
},
};
En este archivo, podemos ver que se crean las tablas de notas, tags y notas_tags.
Ejecutando nuestro primer runner
Ahora, vamos a ejecutar las migraciones. Para ello, vamos a modificar el archivo manager.ts
y añadir el runner que acabamos de crear.
/* Importamos el runner */
import { createInitialSQLRunner } from './initial-database.migration';
const runners: SQLRunner[] = [
/* Añadimos el runner a la lista de runners */
createInitialSQLRunner,
];
Una vez que se haya reiniciado la aplicación, podremos ver en la consola que se ha ejecutado el runner.
> Runner @create_initial_database_runner completed successfully
Creando nuestro primer seeder
Ahora, vamos a crear un seeder que nos permitirá insertar datos iniciales en nuestra base de datos. Para este ejemplo, lo vamos a generar en la carpeta lib/database/seeders/default-tags.seeder.ts
.
import { SQLiteDatabase } from 'expo-sqlite';
import { SQLRunner } from './manager';
export const createDefaultTagsSeeder: SQLRunner = {
key: '@create_default_tags_seeder',
run: async (db: SQLiteDatabase) => {
try {
/* Definimos los tags a registrar */
const tags = ['tag1', 'tag2', 'tag3'];
/* Insertamos los tags */
for (const tag of tags) {
await db.runAsync('INSERT OR IGNORE INTO tags (name) VALUES (?)', [tag]);
}
} catch (error) {
console.error('Error processing @create_default_tags_seeder runner:', error);
throw error;
}
},
};
Ahora, vamos a ejecutar el seeder. Para ello, vamos a modificar el archivo manager.ts
y añadir el seeder que acabamos de crear.
/* Importamos el seeder */
import { createDefaultTagsSeeder } from './default-tags.seeder';
const runners: SQLRunner[] = [
/* Añadimos el seeder a la lista de runners */
createDefaultTagsSeeder,
];
Con esto, ya hemos creado nuestro primer runner de migración y un seeder. Ahora, podemos ejecutar la aplicación y ver que se han creado las tablas y se han insertado los tags por defecto.
Ahora, con esta implementación, podremos versionar los cambios en la base de datos y ejecutar los seeders cuando sea necesario. Esto nos brinda una mejor experiencia de desarrollo para mantener nuestra base de datos actualizada y en sincronía con el código.
Conclusión
Por ahora, esta implementación es muy sencilla y funcional. Sin embargo, esta puede ser mejorada para que sea más robusta y fácil de mantener. O incluso convertirla en algo más genérico para que pueda ser utilizada en otros proyectos.
Si tienes alguna sugerencia o mejora, no dudes en compartirla.
Referencias
