Introducción
Como desarrollador de software, en algún momento, es posible que deba lidiar con las migraciones de bases de datos de una forma u otra.
A medida que el software o las aplicaciones evolucionan y mejoran con el tiempo, su base de datos también debe hacerlo. Y tenemos que asegurarnos de que los datos sigan siendo consistentes en toda la aplicación.
Hay varias formas diferentes en que un esquema puede cambiar de una versión de su aplicación a la siguiente.
- Se agrega un nuevo miembro
- Un miembro es eliminado
- Se cambia el nombre de un miembro
- Se cambia el tipo de miembro
- Se cambia la representación de un miembro
Entonces, ¿cómo maneja todos los cambios anteriores?
vía GIPHY
Hay dos estrategias:
- Escriba un script que se encargará de actualizar el esquema y de degradarlo a versiones anteriores.
- Actualice sus documentos a medida que se utilizan
El segundo depende mucho más del código y debe permanecer en su base de código. Si el código se elimina de alguna manera, muchos de los documentos no se pueden actualizar.
Por ejemplo, si ha habido 3 versiones de un documento, [1, 2 y 3] y eliminamos el código de actualización de la versión 1 a la versión 2, cualquier documento que aún exista como versión 1 no se puede actualizar. Personalmente, veo esto como una sobrecarga para mantener el código y se vuelve inflexible.
Dado que este artículo trata sobre la automatización de migraciones, le mostraré cómo puede escribir un script simple que se encargue de los cambios de esquema y de las pruebas unitarias.
Se ha agregado un miembro
Cuando se ha agregado un miembro al esquema, el documento existente no tendrá la información. Por lo tanto, debe consultar todos los documentos donde este miembro no existe y actualizarlos.
Procedamos a escribir código.
Ya hay bastantes módulos npm disponibles, pero he usado la biblioteca node-migrate. También probé otros, pero algunos de ellos ya no están bien mantenidos y tuve problemas para configurar otros.
Prerrequisitos
- node-migrate - Marco de migración abstracto para Node
- mongodb: un controlador nativo de MongoDB para Nodejs
- Mocha - Marco de prueba
- Chai - Biblioteca de afirmaciones para escribir casos de prueba
- Bluebird: biblioteca de promesas para manejar llamadas API asíncronas
- mkdirp: Me gusta
mkdir -p
pero en Node.js - rimraf:
rm -rf
para nodo
Estado de migración
Un estado de migración es la clave más importante para realizar un seguimiento de su migración actual. Sin él, no podremos rastrear:
- Cuantas migraciones se han realizado
- Cual fue la ultima migracion
- ¿Cuál es la versión actual del esquema que estamos usando?
Y sin estados, no hay forma de revertir, actualizar y viceversa a un estado diferente.
Creando Migraciones
Para crear una migración, ejecute migrate create
By default, a file in
./migrations/
will be created with the following content:
'use strict' module.exports.up = function (next) { next() } module.exports.down = function (next) { next() }
Let’s take an example of a
User
schema where we have a property name
which includes both first
and last
name.
Now we want to change the schema to have a separate
last
name property.
So in order to automate this, we will read
name
at runtime and extract the last name and save it as new property.
Create a migration with this command:
$ migrate create add-last-name.js
This call will create
./migrations/{timestamp in milliseconds}-add-last-name.js
under the migrations
folder in the root directory.
Let's write code for adding a last name to the schema and also for removing it.
Up Migration
We will find all the users where
lastName
property doesn’t exist and create a new property lastName
in those documents.
'use strict' const Bluebird = require('bluebird') const mongodb = require('mongodb') const MongoClient = mongodb.MongoClient const url = 'mongodb://localhost/Sample' Bluebird.promisifyAll(MongoClient) module.exports.up = next => { let mClient = null return MongoClient.connect(url) .then(client => { mClient = client return client.db(); }) .then(db => { const User = db.collection('users') return User .find({ lastName: { $exists: false }}) .forEach(result => { if (!result) return next('All docs have lastName') if (result.name) { const { name } = result result.lastName = name.split(' ')[1] result.firstName = name.split(' ')[0] } return db.collection('users').save(result) }) }) .then(() => { mClient.close() return next() }) .catch(err => next(err)) }
Down Migration
Similarly, let’s write a function where we will remove
lastName
:
module.exports.down = next => { let mClient = null return MongoClient .connect(url) .then(client => { mClient = client return client.db() }) .then(db => db.collection('users').update( { lastName: { $exists: true } }, { $unset: { lastName: "" }, }, { multi: true } )) .then(() => { mClient.close() return next() }) .catch(err => next(err)) }
Running Migrations
Check out how migrations are executed here: running migrations.
Writing Custom State Storage
By default,
migrate
stores the state of the migrations which have been run in a file (.migrate
).
.migrate
file will contain the following code:
{ "lastRun": "{timestamp in milliseconds}-add-last-name.js", "migrations": [ { "title": "{timestamp in milliseconds}-add-last-name.js", "timestamp": {timestamp in milliseconds} } ] }
But you can provide a custom storage engine if you would like to do something different, like storing them in your database of choice.
A storage engine has a simple interface of
load(fn)
and save(set, fn)
.
As long as what goes in as
set
comes out the same on load
, then you are good to go!
Let’s create file
db-migrate-store.js
in root directory of the project.
const mongodb = require('mongodb') const MongoClient = mongodb.MongoClient const Bluebird = require('bluebird') Bluebird.promisifyAll(MongoClient) class dbStore { constructor () { this.url = 'mongodb://localhost/Sample' . // Manage this accordingly to your environment this.db = null this.mClient = null } connect() { return MongoClient.connect(this.url) .then(client => { this.mClient = client return client.db() }) } load(fn) { return this.connect() .then(db => db.collection('migrations').find().toArray()) .then(data => { if (!data.length) return fn(null, {}) const store = data[0] // Check if does not have required properties if (!Object .prototype .hasOwnProperty .call(store, 'lastRun') || !Object .prototype .hasOwnProperty .call(store, 'migrations')) { return fn(new Error('Invalid store file')) } return fn(null, store) }).catch(fn) } save(set, fn) { return this.connect() .then(db => db.collection('migrations') .update({}, { $set: { lastRun: set.lastRun, }, $push: { migrations: { $each: set.migrations }, }, }, { upsert: true, multi: true, } )) .then(result => fn(null, result)) .catch(fn) } } module.exports = dbStore
load(fn)
In this function we are just verifying if the existing migration document that has been loaded contains the lastRun
property and migrations
array.
save(set,fn)
Here set
is provided by library and we are updating the lastRun
value and appending migrations
to the existing array.
You might be wondering where the above file
db-migrate-store.js
is used. We are creating it because we want to store the state in the database, not in the code repository.
Below are test examples where you can see its usage.
Automate migration testing
Install Mocha:
$ npm install -g mocha
We installed this globally so we’ll be able to run mocha
from the terminal.Structure
To set up the basic tests, create a new folder called “test” in the project root, then within that folder add a folder called migrations.
Your file/folder structure should now look like this:
├── package.json ├── app │ ├── server.js │ ├── models │ │ └── user.js │ └── routes │ └── user.js └── test migrations └── create-test.js └── up-test.js └── down-test.js
Test — Create Migration
Goal: It should create the migrations directory and file.
$ migrate create add-last-name
This will implicitly create file ./migrations/{timestamp in milliseconds}-add-last-name.js
under the migrations
folder in the root directory.
Now add the following code to the create-test.js
file:
const Bluebird = require('bluebird') const { spawn } = require('child_process') const mkdirp = require('mkdirp') const rimraf = require('rimraf') const path = require('path') const fs = Bluebird.promisifyAll(require('fs')) describe('[Migrations]', () => { const run = (cmd, args = []) => { const process = spawn(cmd, args) let out = "" return new Bluebird((resolve, reject) => { process.stdout.on('data', data => { out += data.toString('utf8') }) process.stderr.on('data', data => { out += data.toString('utf8') }) process.on('error', err => { reject(err) }) process.on('close', code => { resolve(out, code) }) }) } const TMP_DIR = path.join(__dirname, '..', '..', 'tmp') const INIT = path.join(__dirname, '..', '..', 'node_modules/migrate/bin', 'migrate-init') const init = run.bind(null, INIT) const reset = () => { rimraf.sync(TMP_DIR) rimraf.sync(path.join(__dirname, '..', '..', '.migrate')) } beforeEach(reset) afterEach(reset) describe('init', () => { beforeEach(mkdirp.bind(mkdirp, TMP_DIR)) it('should create a migrations directory', done => { init() .then(() => fs.accessSync(path.join(TMP_DIR, '..', 'migrations'))) .then(() => done()) .catch(done) }) }) })
In the above test, we are using the migrate-init
command to create the migrations directory and deleting it after each test case using rimraf
which is rm -rf
in Unix.
Later we are using fs.accessSync
function to verify migrations
folder exists or not.
Test — Up Migration
Goal: It should add lastName
to schema and store migration state.
Add the following code to the up-test.js
file:
const chance = require('chance')() const generateUser = () => ({ email: chance.email(), name: `${chance.first()} ${chance.last()}` }) const migratePath = path.join(__dirname, '..', '..', 'node_modules/migrate/bin', 'migrate') const migrate = run.bind(null, migratePath) describe('[Migration: up]', () => { before(done => { MongoClient .connect(url) .then(client => { db = client.db() return db.collection('users').insert(generateUser()) }) .then(result => { if (!result) throw new Error('Failed to insert') return done() }).catch(done) }) it('should run up on specified migration', done => { migrate(['up', 'mention here the file name we created above', '--store=./db-migrate-store.js']) .then(() => { const promises = [] promises.push( db.collection('users').find().toArray() ) Bluebird.all(promises) .then(([users]) => { users.forEach(elem => { expect(elem).to.have.property('lastName') }) done() }) }).catch(done) }) after(done => { rimraf.sync(path.join(__dirname, '..', '..', '.migrate')) db.collection('users').deleteMany() .then(() => { rimraf.sync(path.join(__dirname, '..', '..', '.migrate')) return done() }).catch(done) }) })
Similarly, you can write down migration and before()
and after()
functions remain basically the same.
Conclusion
Hopefully you can now automate your schema changes with proper testing. :)
Grab the final code from repository.
Don’t hesitate to clap if you considered this a worthwhile read!