Mongoose con Node.js - Modelado de datos de objetos

En este tutorial, crearemos una aplicación de demostración que explica cómo funciona Mongoose con Node.js. Realizaremos el modelado de datos de objetos e implementaremos la funcionalidad CRUD.

Introducción

NoSQL trajo flexibilidad al mundo tabular de las bases de datos. MongoDB en particular se convirtió en una excelente opción para almacenar documentos JSON no estructurados. Los datos comienzan como JSON en la interfaz de usuario y se someten a muy pocas transformaciones para almacenarse, por lo que obtenemos los beneficios de un mayor rendimiento y un menor tiempo de procesamiento.

Pero NoSQL no significa una falta total de estructura. Todavía necesitamos validar y convertir nuestros datos antes de almacenarlos, y es posible que aún necesitemos aplicarles alguna lógica comercial. Ese es el lugar que ocupa Mangosta.

En este artículo, aprenderemos a través de una aplicación de ejemplo cómo podemos usar Mongoose para modelar nuestros datos y validarlos antes de almacenarlos en MongoDB.

Escribiremos el modelo para una aplicación de Genealogía, una Persona con algunas propiedades personales, incluyendo quiénes son sus padres. También veremos cómo podemos usar este modelo para crear y modificar Personas y guardarlas en MongoDB.

¿Qué es la mangosta?

Cómo funciona MongoDB

Para comprender qué es Mongoose, primero debemos comprender en términos generales cómo funciona MongoDB. La unidad básica de datos que podemos guardar en MongoDB es un Documento. Aunque se almacena como binario, cuando consultamos una base de datos obtenemos su representación como un objeto JSON.

Los documentos relacionados se pueden almacenar en colecciones, de forma similar a las tablas en las bases de datos relacionales. Sin embargo, aquí es donde termina la analogía, porque definimos qué considerar "documentos relacionados".

MongoDB no impondrá una estructura en los documentos. Por ejemplo, podríamos guardar este documento en la colección Person:

1
2
3
{
  "name": "Alice"
}

Y luego, en la misma colección, podríamos guardar un documento aparentemente no relacionado sin propiedades ni estructura compartidas:

1
2
3
4
{
  "latitude": 53.3498,
  "longitude": 6.2603
}

Aquí radica la novedad de las bases de datos NoSQL. Creamos significado para nuestros datos y los almacenamos de la manera que consideramos mejor. La base de datos no impondrá ninguna limitación.

Propósito de mangosta {#propósito de mangosta}

Aunque MongoDB no impondrá una estructura, las aplicaciones generalmente administran datos con una. Recibimos datos y necesitamos validarlos para asegurarnos de que lo que recibimos es lo que necesitamos. También es posible que necesitemos procesar los datos de alguna manera antes de guardarlos. Aquí es donde entra en juego Mongoose.

Mongoose es un paquete NPM para aplicaciones NodeJS. Permite definir esquemas para que encajen nuestros datos, al mismo tiempo que abstrae el acceso a MongoDB. De esta manera podemos asegurarnos de que todos los documentos guardados compartan una estructura y contengan las propiedades requeridas.

Veamos ahora cómo definir un esquema.

Instalación de Mongoose y creación del esquema de persona {#instalación de mongoose y creación del esquema de persona}

Iniciemos un proyecto de Nodo con propiedades predeterminadas y un esquema de persona:

1
$ npm init -y

Con el proyecto inicializado, sigamos adelante e instalemos mongoose usando npm:

1
$ npm install --save mongoose

mongoose incluirá automáticamente el módulo NPM mongodb también. No lo usarás directamente tú mismo. Será manejado por Mongoose.

Para trabajar con Mongoose, queremos importarlo a nuestros scripts:

1
let mongoose = require('mongoose');

Y luego conéctese a la base de datos con:

1
mongoose.connect('mongodb://localhost:27017/genealogy', {useNewUrlParser: true, useUnifiedTopology: true});

Dado que la base de datos aún no existe, se creará una. Usaremos la herramienta más reciente para analizar la cadena de conexión, configurando useNewUrlParser en true y también usaremos el controlador MongoDB más reciente con useUnifiedTopology como true.

mongoose.connect() asume que el servidor MongoDB se ejecuta localmente en el puerto predeterminado y sin credenciales. Una manera fácil de tener MongoDB funcionando de esa manera es Estibador:

1
$ docker run -p 27017:27017 mongo

El contenedor creado será suficiente para que podamos probar Mongoose, aunque los datos guardados en MongoDB no serán persistentes.

Esquema y modelo de persona

Después de las explicaciones necesarias anteriores, ahora podemos centrarnos en escribir nuestro esquema de persona y compilar un modelo a partir de él.

Un esquema en Mongoose se asigna a una colección MongoDB y define el formato para todos los documentos en esa colección. Todas las propiedades dentro del esquema deben tener un SchemaType asignado. Por ejemplo, el nombre de nuestra Persona se puede definir de esta manera:

1
2
3
const PersonSchema = new mongoose.Schema({
    name:  { type: String},
});

O incluso más simple, así:

1
2
3
const PersonSchema = new mongoose.Schema({
    name: String,
});

String es uno de varios SchemaTypes definidos por Mongoose. El resto lo podéis encontrar en la Documentación de mangosta.

Referencia a otros esquemas

Podemos esperar que todas las aplicaciones medianas tengan más de un esquema, y ​​posiblemente esos esquemas estén vinculados de alguna manera.

En nuestro ejemplo, para representar un árbol genealógico necesitamos agregar dos atributos a nuestro esquema:

1
2
3
4
5
const PersonSchema = new mongoose.Schema({
    // ...
    mother: { type: mongoose.Schema.Types.ObjectId, ref: 'Person' },
    father: { type: mongoose.Schema.Types.ObjectId, ref: 'Person' },
});

Una persona puede tener una ‘madre’ y un ‘padre’. La forma de representar esto en Mongoose es guardando el ID del documento al que se hace referencia, mongoose.Schema.Types.ObjectId, no el objeto en sí.

La propiedad ref debe ser el nombre del modelo al que hacemos referencia. Veremos más sobre los modelos más adelante, pero por ahora es suficiente saber que un esquema se relaciona con un solo modelo, y 'Person' es el modelo de PersonSchema.

Nuestro caso es un poco especial porque tanto madre como padre también contendrán personas, pero la forma de definir estas relaciones es la misma en todos los casos.

Validación integrada

Todos los SchemaType vienen con una validación incorporada predeterminada. Podemos definir límites y otros requisitos dependiendo del SchemaType seleccionado. Para ver algunos ejemplos, agreguemos un ‘apellido’, ‘año de nacimiento’ y ’notas’ a nuestra ‘Persona’:

1
2
3
4
5
6
const PersonSchema = new mongoose.Schema({
    name: { type: String, index: true, required: true },
    surname: { type: String, index: true },
    yearBorn: { type: Number, min: -5000, max: (new Date).getFullYear() },
    notes: { type: String, minlength: 5 },
});

Todos los SchemaTypes incorporados pueden ser requeridos. En nuestro caso queremos que todas las personas tengan al menos un nombre. El tipo Número permite establecer valores mínimos y máximos, que incluso se pueden calcular.

La propiedad index hará que Mongoose cree un índice en la base de datos. Esto facilita la ejecución eficiente de las consultas. Arriba, definimos el nombre y el apellido de la persona como índices. Siempre buscaremos personas por sus nombres.

Validación personalizada

Los SchemaTypes incorporados permiten la personalización. Esto es especialmente útil cuando tenemos una propiedad que puede contener solo ciertos valores. Agreguemos la propiedad photosURLs a nuestra Persona, una matriz de URL de sus fotos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const PersonSchema = new mongoose.Schema({
    // ...
    photosURLs: [
      {
        type: String,
        validate: {
          validator: function(value) {
            const urlPattern = /(http|https):\/\/(\w+:{0,1}\w*#)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%#!\-/]))?/;
            const urlRegExp = new RegExp(urlPattern);
            return value.match(urlRegExp);
          },
          message: props => `${props.value} is not a valid URL`
        }
      }
    ],
});

photosURLs es solo una matriz de cadenas, photosURLs: [String]. Lo que hace que esta propiedad sea especial es que necesitamos una validación personalizada para confirmar que los valores agregados tengan el formato de una URL de Internet.

La función validator() anterior utiliza una expresión regular que coincide con las URL típicas de Internet, que debe comenzar con http(s)://.

Si necesitamos un SchemaType más complejo podemos crear nuestro propio, pero hacemos bien en buscar si ya está disponible.

Por ejemplo, el paquete mangosta-tipo-url agrega un SchemaType personalizado que podríamos haber usado, mongoose.SchemaTypes.Url .

Propiedades virtuales

Los virtuales son propiedades de documentos que no se guardan en la base de datos. Son el resultado de un cálculo. En nuestro ejemplo, sería útil establecer el nombre completo de una persona en una cadena en lugar de separarlo en nombre y apellido.

Veamos cómo lograr esto después de nuestra definición de esquema inicial:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
PersonSchema.virtual('fullName').
    get(function() { 
      if(this.surname)
        return this.name + ' ' + this.surname; 
      return this.name;
    }).
    set(function(fullName) {
      fullName = fullName.split(' ');
      this.name = fullName[0];
      this.surname = fullName[1];
    });

La propiedad virtual fullName anterior hace algunas suposiciones en aras de la simplicidad: cada persona tiene al menos un nombre, o un nombre y un apellido. Tendríamos problemas si una persona tiene un segundo nombre o un nombre o apellido compuesto. Todas esas limitaciones podrían corregirse dentro de las funciones get() y set() definidas anteriormente.

Debido a que los virtuales no se guardan en la base de datos, no podemos usarlos como filtro cuando buscamos personas en la base de datos. En nuestro caso necesitaríamos usar nombre y apellido.

Programa intermedio

El middleware son funciones o ganchos que se pueden ejecutar antes o después de los métodos estándar de Mongoose, como save() o find(), por ejemplo.

Una persona puede tener una ‘madre’ y un ‘padre’. Como dijimos antes, guardamos estas relaciones almacenando la identificación del objeto como propiedades de la persona, no los objetos en sí. Sería bueno llenar ambas propiedades con los objetos mismos en lugar de solo las ID.

Esto se puede lograr como una función pre() asociada al método findOne() Mongoose:

1
2
3
4
PersonSchema.pre('findOne', function(next) {
    this.populate('mother').populate('father');
    next();
});

La función anterior necesita llamar a la función recibida como parámetro, next() para seguir procesando otros enlaces.

populate() es un método Mongoose para reemplazar ID con los objetos que representan, y lo usamos para obtener los padres cuando buscamos una sola persona.

Podríamos agregar este gancho a otras funciones de búsqueda, como find(). Incluso podríamos encontrar padres recursivamente si quisiéramos. Pero debemos manejar populate() con cuidado, ya que cada llamada es una búsqueda de la base de datos.

Crear el modelo para un esquema

Para comenzar a crear documentos basados ​​en nuestro esquema de Persona, el último paso es compilar un modelo basado en el esquema:

1
const Person = mongoose.model('Person', PersonSchema);

El primer argumento será el nombre singular de la colección a la que nos referimos. Este es el valor que le dimos a la propiedad ‘ref’ de las propiedades ‘madre’ y ‘padre’ de nuestra persona. El segundo argumento es el Esquema que definimos antes.

El método model() hace una copia de todo lo que definimos en el esquema. También contiene todos los métodos Mongoose que usaremos para interactuar con la base de datos.

El modelo es lo único que necesitamos a partir de ahora. Incluso podríamos usar módulo.exportaciones para que la persona esté disponible en otros módulos de nuestra app:

1
2
module.exports.Person = mongoose.model('Person', PersonSchema);
module.exports.db = mongoose;

También exportamos el módulo mongoose. Necesitaremos que se desconecte de la base de datos antes de que finalice la aplicación.

Podemos importar el módulo de esta manera:

1
const {db, Person} = require('./persistence');

Cómo usar el modelo

El modelo que compilamos en la última sección contiene todo lo que necesitamos para interactuar con la colección en la base de datos.

Veamos ahora cómo usaríamos nuestro modelo para todas las operaciones CRUD.

Crear personas

Podemos crear una persona simplemente haciendo:

1
let alice = new Person({name: 'Alice'});

El nombre es la única propiedad requerida. Vamos a crear otra persona pero usando la propiedad virtual esta vez:

1
let bob = new Person({fullName: 'Bob Brown'});

Ahora que tenemos nuestras dos primeras personas, podemos crear una nueva con todas las propiedades llenas, incluidos los padres:

1
2
3
4
5
6
7
8
let charles = new Person({
  fullName: 'Charles Brown',
  photosURLs: ['https://bit.ly/34Kvbsh'],
  yearBorn: 1922,
  notes: 'Famous blues singer and pianist. Parents not real.',
  mother: alice._id,
  father: bob._id,
});

Todos los valores para esta última persona se establecen como válidos, ya que la validación generaría un error tan pronto como se ejecute esta línea. Por ejemplo, si hubiéramos establecido la URL de la primera foto en algo que no sea un enlace, obtendríamos el error:

1
ValidationError: Person validation failed: photosURLs.0: wrong_url is not a valid URL

Como se explicó anteriormente, los padres se completaron con las identificaciones de las dos primeras personas, en lugar de los objetos.

Hemos creado tres personas, pero aún no están almacenadas en la base de datos. Hagamos eso a continuación:

1
2
alice.save();
bob.save();

Las operaciones que involucran la base de datos son asincrónicas. Si queremos esperar a que se complete, podemos usar async/await:

1
await charles.save();

Ahora que todas las personas están guardadas en la base de datos, podemos recuperarlas con los métodos find() y findOne().

Recuperar una o más personas

Todos los métodos de búsqueda en Mongoose requieren un argumento para filtrar la búsqueda. Recuperemos a la última persona que creamos:

1
let dbCharles = await Person.findOne({name: 'Charles', surname: 'Brown'}).exec();

findOne() devuelve una consulta, por lo que para obtener un resultado necesitamos ejecutarlo con exec() y luego esperar el resultado con await.

Debido a que adjuntamos un gancho al método findOne() para completar los padres de la persona, ahora podemos acceder a ellos directamente:

1
console.log(dbCharles.mother.fullName);

En nuestro caso, sabemos que la consulta devolverá solo un resultado, pero incluso si más de una persona coincide con el filtro, solo se devolverá el primer resultado.

Podemos obtener más de un resultado si usamos el método find():

1
let all = await Person.find({}).exec();

Obtendremos una matriz sobre la que podemos iterar.

Actualizar personas

Si ya tenemos una persona, ya sea porque la acabamos de crear o porque la recuperamos, podemos actualizar y guardar los cambios haciendo:

1
2
3
4
alice.surname = 'Adams';
charles.photosURLs.push('https://bit.ly/2QJCnMV');
await alice.save();
await charles.save();

Debido a que ambas personas ya existen en la base de datos, Mongoose enviará un comando de actualización solo con los campos modificados, no con todo el documento.

Eliminar personas

Al igual que la recuperación, la eliminación se puede realizar para una o varias personas. Hagamos eso a continuación:

1
2
await Person.deleteOne({name: 'Alice'});
await Person.deleteMany({}).exec();

Después de ejecutar estos dos comandos, la colección estará vacía.

Conclusión

En este artículo hemos visto cómo Mongoose puede ser muy útil en nuestros proyectos NodeJS y MongoDB.

En la mayoría de los proyectos con MongoDB, necesitamos almacenar datos con cierto formato definido. Es bueno saber que Mongoose proporciona una manera fácil de modelar y validar esos datos.

El proyecto de muestra completo se puede encontrar en GitHub.