a
Uso de bases de datos relacionales con Sequelize
En esta sección exploraremos las aplicaciones de Node que usan bases de datos relacionales. Durante la sección construiremos un backend en Node utilizando una base de datos relacional para una aplicación de notas familiar de las secciones 3-5. Para completar esta parte, se necesitará un conocimiento razonable de bases de datos relacionales y SQL. Hay muchos cursos en línea sobre bases de datos SQL, por ejemplo. SQLbolt y Introducción a SQL por Khan Academy.
Hay 24 ejercicios en esta parte, y se debe completar cada ejercicio para completar el curso. Los ejercicios se envían a través del sistema de envíos al igual que en las partes anteriores, pero a diferencia de las partes 0 a 7, el envío va a su propia "instancia de curso".
Ventajas y desventajas de las bases de datos de documentos.
En las secciones anteriores del curso, hemos utilizado la base de datos MongoDB. Mongo es una base de datos de documentos y una de sus características más importante es que no posee esquema, es decir, la base de datos tiene solo un conocimiento muy limitado de qué tipo de datos se almacenan en sus colecciones. El esquema de la base de datos existe solo en el código del programa, que interpreta los datos de una manera específica, por ejemplo, al identificar que algunos de los campos son referencias a objetos en otra colección.
En la aplicación de ejemplo de las partes 3 y 4, la base de datos almacena notas y usuarios.
Una colección de notas que almacena notas tiene el siguiente aspecto:
[
{
"_id": "600c0e410d10256466898a6c",
"content": "HTML is easy"
"date": 2021-01-23T11:53:37.292+00:00,
"important": false
"__v": 0
},
{
"_id": "600c0edde86c7264ace9bb78",
"content": "CSS is hard"
"date": 2021-01-23T11:56:13.912+00:00,
"important": true
"__v": 0
},
]
Los usuarios guardados en la colección users tienen el siguiente aspecto:
[
{
"_id": "600c0e410d10256466883a6a",
"username": "mluukkai",
"name": "Matti Luukkainen",
"passwordHash" : "$2b$10$Df1yYJRiQuu3Sr4tUrk.SerVz1JKtBHlBOARfY0PBn/Uo7qr8Ocou",
"__v": 9,
notes: [
"600c0edde86c7264ace9bb78",
"600c0e410d10256466898a6c"
]
},
]
MongoDB conoce los tipos de los campos de las entidades almacenadas, pero no tiene información sobre a qué colección de entidades se refieren los ID de registro de usuario. A MongoDB tampoco le importa qué campos tienen las entidades almacenadas en las colecciones. Por lo tanto, MongoDB deja totalmente en manos del programador garantizar que la información correcta se almacene en la base de datos.
Hay ventajas y desventajas de no tener un esquema. Una de las ventajas es la flexibilidad que aporta el agnosticismo de esquema: dado que no es necesario definir el esquema a nivel de la base de datos, el desarrollo de la aplicación puede ser más rápido en ciertos casos y más fácil, con menos esfuerzo necesario para definir y modificar el esquema en cualquier caso. Los problemas de no tener un esquema están relacionados con la propensión a errores: todo se deja en manos del programador. La base de datos en sí no tiene forma de verificar si los datos que contiene son honestos, es decir, si todos los campos obligatorios tienen valores, si los campos de tipo de referencia se refieren a entidades existentes del tipo correcto en general, etc.
Las bases de datos relacionales en las que se centra esta sección, por otro lado, se basan en gran medida en la existencia de un esquema, y las ventajas y desventajas de las bases de datos de esquema son casi opuestas en comparación con las bases de datos sin esquema.
La razón por la que las secciones anteriores del curso usaron MongoDB es precisamente por su naturaleza sin esquema, lo que ha facilitado el uso de la base de datos para alguien con poco conocimiento de bases de datos relacionales. Para la mayoría de los casos de uso de este curso, personalmente habría optado por utilizar una base de datos relacional.
Base de datos de la aplicacion
Para nuestra aplicación necesitamos una base de datos relacional. Hay muchas opciones, pero usaremos la solución de código abierto más popular actualmente PostgreSQL. Puede instalar Postgres (como suele llamarse a la base de datos) en su máquina, si así lo desea. Una opción más fácil sería usar. También puede aprovechar las lecciones del curso parte 12 y usar Postgres localmente usando Docker.
Sin embargo, aprovecharemos el hecho de que es posible crear una base de datos de Postgres para la aplicación en la plataforma de servicios en la nube de Heroku, que ya conocemos de las partes 3 y 4.
En el material teórico de esta sección, crearemos una versión habilitada para Postgres desde el backend de la aplicación de almacenamiento de notas, que se creó en las secciones 3 y 4.
Ahora vamos a crear un directorio adecuado dentro de la aplicación Heroku, agregarle una base de datos y usar el comando heroku config para obtener la cadena de conexión, que se requiere para conectarse a la base de datos:
heroku create
# Returns an app-name for the app you just created in heroku.
heroku addons:create heroku-postgresql:hobby-dev -a <app-name>
heroku config -a <app-name>
=== cryptic-everglades-76708 Config Vars
DATABASE_URL: postgres://<username>:thepasswordishere@<host-of-postgres-addon>:5432/<db-name>
Particularmente cuando se utiliza una base de datos relacional, también es esencial acceder a la base de datos directamente. Hay muchas maneras de hacer esto, hay varias interfaces gráficas de usuario diferentes, como pgAdmin. Sin embargo, utilizaremos la herramienta de línea de comandos de Postgres psql.
Se puede acceder a la base de datos ejecutando el comando psql en el servidor de Heroku de la siguiente manera (tenga en cuenta que los parámetros del comando dependen de la URL de conexión de la base de datos de Heroku):
heroku run psql -h <host-of-postgres-addon> -p 5432 -U <username> <dbname> -a <app-name>
Después de ingresar la contraseña, probemos con el comando psql principal \d, que le indica el contenido de la base de datos:
Password for user <username>:
psql (13.4 (Ubuntu 13.4-1.pgdg20.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.
postgres=# \d
Did not find any relations.
Como se puede suponer, actualmente no hay nada en la base de datos.
Vamos a crear una tabla para notas:
CREATE TABLE notes (
id SERIAL PRIMARY KEY,
content text NOT NULL,
important boolean,
date time
);
Algunos puntos: la columna id se define como una clave principal, lo que significa que el valor de la columna id debe ser único para cada fila de la tabla y el valor no debe estar vacío. El tipo de esta columna se define como SERIAL, que no es el tipo real sino una abreviatura de una columna de enteros al que Postgres asigna automáticamente un valor único y creciente al crear filas. La columna denominada contenido con tipo texto se define de tal manera que se le debe asignar un valor.
Veamos la situación desde la consola. Primero, el comando \d, que nos dice qué tablas hay en la base de datos:
postgres=# \d
List of relations
Schema | Name | Type | Owner
--------+--------------+----------+----------------
public | notes | table | username
public | notes_id_seq | sequence | username
(2 rows)
Además de la tabla notes, Postgres creó una subtabla llamada notes_id_seq, que realiza un seguimiento de qué valor se asigna a la id columna al crear la siguiente nota.
Con el comando \d notas, podemos ver como se define la tabla notas:
postgres=# \d notes;
Table "public.notes"
Column | Type | Collation | Nullable | Default
-----------+------------------------+-----------+----------+-----------------------------------
id | integer | not null | nextval('notes_id_seq'::regclass)
content | text | | not null |
important | boolean | | | |
date | time without time zone | | | |
Indexes:
"notes_pkey" PRIMARY KEY, btree (id)
Por lo tanto, la columna id tiene un valor predeterminado, que se obtiene llamando a la función interna de Postgres nextval.
Agreguemos algo de contenido a la tabla:
insert into notes (content, important) values ('Relational databases rule the world', true);
insert into notes (content, important) values ('MongoDB is webscale', false);
Y veamos cómo se ve el contenido creado:
postgres=# select * from notes;
id | content | important | date
----+-------------------------------------+-----------+------
1 | relational databases rule the world | t |
2 | MongoDB is webscale | f |
(2 rows)
Si tratamos de almacenar datos en la base de datos que no están de acuerdo con el esquema, no tendrá éxito. No puede faltar el valor de una columna obligatoria:
postgres=# insert into notes (important) values (true);
ERROR: null value in column "content" of relation "notes" violates not-null constraint
DETAIL: Failing row contains (9, null, t, null).
El valor de la columna no puede ser del tipo incorrecto:
postgres=# insert into notes (content, important) values ('only valid data can be saved', 1);
ERROR: column "important" is of type boolean but expression is of type integer
LINE 1: ...tent, important) values ('only valid data can be saved', 1); ^
Tampoco se aceptan columnas que no existen en el esquema:
postgres=# insert into notes (content, important, value) values ('only valid data can be saved', true, 10);
ERROR: column "value" of relation "notes" does not exist
LINE 1: insert into notes (content, important, value) values ('only ...
A continuación, es hora de pasar a acceder a la base de datos desde la aplicación.
Aplicación en Node, usando una base de datos relacional
Iniciemos la aplicación como de costumbre con npm init e instalemos nodemon como una dependencia de desarrollo y también las siguientes dependencias de tiempo de ejecución:
npm install express dotenv pg sequelize
De estos, el último sequelize es la biblioteca a través de la cual usamos Postgres. Sequelize es una biblioteca llamada [Mapeo relacional de objetos] (https://en.wikipedia.org/wiki/Object%E2%80%93relational_mapping) (ORM) que le permite almacenar objetos de JavaScript en una base de datos relacional sin usar el Lenguaje SQL en sí mismo, similar a Mongoose que usamos con MongoDB.
Probemos que podemos conectarnos con éxito. Cree el archivo index.js y agregue el siguiente contenido:
require('dotenv').config()
const { Sequelize } = require('sequelize')
const sequelize = new Sequelize(process.env.DATABASE_URL, {
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
},
})
const main = async () => {
try {
await sequelize.authenticate()
console.log('Connection has been established successfully.')
sequelize.close()
} catch (error) {
console.error('Unable to connect to the database:', error)
}
}
main()
La cadena de conexión de la base de datos, que es revelada por el comando heroku config debe almacenarse en un archivo .env, el contenido debe ser algo como lo siguiente:
$ cat .env
DATABASE_URL=postgres://<username>:thepasswordishere@ec2-54-83-137-206.compute-1.amazonaws.com:5432/<databasename>
Probemos una conexión exitosa:
$ node index.js
Executing (default): SELECT 1+1 AS result
Connection has been established successfully.
Si la conexión funciona, podemos ejecutar la primera consulta. Modifiquemos el programa de la siguiente manera:
require('dotenv').config()
const { Sequelize, QueryTypes } = require('sequelize')
const sequelize = new Sequelize(process.env.DATABASE_URL, {
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
},
});
const main = async () => {
try {
await sequelize.authenticate()
const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) console.log(notes) sequelize.close() } catch (error) {
console.error('Unable to connect to the database:', error)
}
}
main()
La ejecución de la aplicación debe imprimir de la siguiente manera:
Executing (default): SELECT * FROM notes
[
{
id: 1,
content: 'Relational databases rule the world',
important: true,
date: null
},
{
id: 2,
content: 'MongoDB is webscale',
important: false,
date: null
}
]
Aun cuando Sequelize es una biblioteca ORM, puede existir casos aislados en los que exista la necesidad de escribir SQL, para ello solo usamos [SQL directo] (https://sequelize.org/master/manual/raw-queries.html) con el método de sequelize [query] (https://sequelize.org/api/v6/class/src/sequelize.js~sequelize#instance-method-query).
Como todo parece estar funcionando, cambiemos la aplicación a una aplicación web.
require('dotenv').config()
const { Sequelize, QueryTypes } = require('sequelize')
const express = require('express')const app = express()
const sequelize = new Sequelize(process.env.DATABASE_URL, {
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
},
});
app.get('/api/notes', async (req, res) => { const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) res.json(notes)})const PORT = process.env.PORT || 3001app.listen(PORT, () => { console.log(`Server running on port ${PORT}`)})
La aplicación parece estar funcionando. Sin embargo, ahora cambiemos a usar Sequelize en lugar de SQL, ya que está destinado a usarse.
El Modelo
Al usar Sequelize, cada tabla en la base de datos está representada por un [modelo] (https://sequelize.org/master/manual/model-basics.html), que es efectivamente su propia clase de JavaScript. Ahora definamos el modelo Nota correspondiente a la tabla notas para la aplicación cambiando el código al siguiente formato:
require('dotenv').config()
const { Sequelize, Model, DataTypes } = require('sequelize')const express = require('express')
const app = express()
const sequelize = new Sequelize(process.env.DATABASE_URL, {
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false
}
},
});
class Note extends Model {}Note.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, content: { type: DataTypes.TEXT, allowNull: false }, important: { type: DataTypes.BOOLEAN }, date: { type: DataTypes.DATE }}, { sequelize, underscored: true, timestamps: false, modelName: 'note'})
app.get('/api/notes', async (req, res) => {
const notes = await Note.findAll() res.json(notes)
})
const PORT = process.env.PORT || 3001
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
Algunos comentarios sobre el código: No hay nada sorprendente en la definición del modelo Nota, cada columna tiene un tipo definido, así como otras propiedades si es necesario, como si es la clave principal de la tabla. El segundo parámetro en la definición del modelo contiene el atributo sequelize así como otra información de configuración. También definimos que la tabla no tiene que usar las columnas de marcas de tiempo (created_at and updated_at).
También definimos underscored: true, lo que significa que los nombres de las tablas se derivan de los nombres de los modelos como versiones en plural snake case. Prácticamente esto significa que, si el nombre del modelo, como en nuestro caso, es "Nota", entonces el nombre de la tabla correspondiente es su versión plural escrita con una letra inicial minúscula, es decir, notas. Si, por el contrario, el nombre del modelo fuera "dos partes", p. StudyGroup, entonces el nombre de la tabla sería study_groups. Sequelize infiere automáticamente los nombres de las tablas, pero también permite definirlos explícitamente.
La misma política de nomenclatura se aplica a las columnas. Si hubiésemos definido que una nota está asociada a creationYear, es decir, información sobre el año en que fue creada, la definiríamos en el modelo de la siguiente manera:
Note.init({
// ...
creationYear: {
type: DataTypes.INTEGER,
},
})
El nombre de la columna correspondiente en la base de datos sería creation_year. En el código, la referencia a la columna siempre tiene el mismo formato que en el modelo, es decir, en formato "camel case".
También hemos definido modelName: 'note', el "nombre del modelo" predeterminado sería Note en mayúsculas. Sin embargo, queremos tener una inicial en minúscula, esto hará que algunas cosas sean un poco más convenientes en el futuro.
La operación de la base de datos es fácil de hacer usando la interfaz de consulta proporcionada por los modelos, el método findAll funciona exactamente como su nombre indica:
app.get('/api/notes', async (req, res) => {
const notes = await Note.findAll() res.json(notes)
})
La consola le dice que la llamada al método Note.findAll() genera la siguiente consulta:
Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note";
A continuación, implementemos un endpoint para crear nuevas notas:
app.use(express.json())
// ...
app.post('/api/notes', async (req, res) => {
console.log(req.body)
const note = await Note.create(req.body)
res.json(note)
})
La creación de una nueva nota se realiza llamando al método create del modelo Note y pasando como parámetro un objeto que define los valores de las columnas.
En lugar del método create, también es posible guardar en una base de datos usando primero el método build para crear un objeto modelo a partir de los datos deseados y luego llamar al método save en él:
const note = Note.build(req.body)
await note.save()
Llamar al método build aún no guarda el objeto en la base de datos, por lo que aún es posible editar el objeto antes del evento de guardado real:
const note = Note.build(req.body)
note.important = trueawait note.save()
Para el caso de uso del código de ejemplo, el método create es más adecuado, así que sigamos con eso.
Si el objeto que se está creando no es válido, aparece un mensaje de error como resultado. Por ejemplo, al intentar crear una nota sin contenido, la operación falla y la consola revela que el motivo es SequelizeValidationError: notNull Violation Note.content can be null:
(node:39109) UnhandledPromiseRejectionWarning: SequelizeValidationError: notNull Violation: Note.content cannot be null
at InstanceValidator._validate (/Users/mluukkai/opetus/fs-psql/node_modules/sequelize/lib/instance-validator.js:78:13)
at processTicksAndRejections (internal/process/task_queues.js:93:5)
Agreguemos un manejo de errores simple al agregar una nueva nota:
app.post('/api/notes', async (req, res) => {
try {
const note = await Note.create(req.body)
return res.json(note)
} catch(error) {
return res.status(400).json({ error })
}
})
Crear tablas automáticamente
Nuestra aplicación ahora tiene un lado desagradable, asume que existe una base de datos con exactamente el esquema correcto, es decir, que la tabla notes ha sido creada con el comando create table apropiado.
Dado que el código del programa se almacena en GitHub, tendría sentido almacenar también los comandos que crean la base de datos en el contexto del código del programa, de modo que el esquema de la base de datos sea definitivamente el mismo que espera el código del programa. Sequelize en realidad puede generar un esquema automáticamente a partir de la definición del modelo utilizando el método sync.
Ahora destruyamos la base de datos desde la consola ingresando el siguiente comando:
drop table notes;
El comando \d
revela que la tabla se ha borrado de la base de datos:
postgres=# \d
Did not find any relations.
La aplicación ya no funciona.
Agreguemos el siguiente comando a la aplicación inmediatamente después de definir el modelo Note:
Note.sync()
Cuando se inicia la aplicación, se imprime lo siguiente en la consola:
Executing (default): CREATE TABLE IF NOT EXISTS "notes" ("id" SERIAL , "content" TEXT NOT NULL, "important" BOOLEAN, "date" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id"));
Es decir, cuando se inicia la aplicación, se ejecuta el comando CREATE TABLE IF NOT EXISTS "notes"... que crea la tabla notes si aún no existe.
Otras operaciones
Completemos la aplicación con algunas operaciones más.
Es posible buscar una sola nota con el método findByPk, porque se recupera en función del identificador de la clave primaria:
app.get('/api/notes/:id', async (req, res) => {
const note = await Note.findByPk(req.params.id)
if (note) {
res.json(note)
} else {
res.status(404).end()
}
})
La recuperación de una sola nota genera el siguiente comando SQL:
Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note" WHERE "note". "id" = '1';
Si no se encuentra ninguna nota, la operación devuelve null y, en este caso, se proporciona el código de estado correspondiente.
La modificación de la nota se realiza de la siguiente manera. Solo se admite la modificación del campo important, ya que el frontend de la aplicación no necesita nada más:
app.put('/api/notes/:id', async (req, res) => {
const note = await Note.findByPk(req.params.id)
if (note) {
note.important = req.body.important
await note.save()
res.json(note)
} else {
res.status(404).end()
}
})
El objeto correspondiente a la fila de la base de datos se recupera de la base de datos utilizando el método findByPk, el objeto se modifica y el resultado se guarda llamando al método save del objeto correspondiente.
El código actual de la aplicación se encuentra en su totalidad en GitHub, rama part13-1.
Imprimiendo los objetos devueltos por Sequelize a la consola
La herramienta más importante del programador de JavaScript es console.log, cuyo uso agresivo controla incluso los peores errores. Agreguemos la impresión de consola a la ruta de una sola nota:
app.get('/api/notes/:id', async (req, res) => {
const note = await Note.findByPk(req.params.id)
if (note) {
console.log(note) res.json(note)
} else {
res.status(404).end()
}
})
Podemos ver que el resultado final no es exactamente lo que esperábamos:
note {
dataValues: {
id: 1,
content: 'Notes are attached to a user',
important: true,
date: 2021-10-03T15:00:24.582Z,
},
_previousDataValues: {
id: 1,
content: 'Notes are attached to a user',
important: true,
date: 2021-10-03T15:00:24.582Z,
},
_changed: Set(0) {},
_options: {
isNewRecord: false,
_schema: null,
_schemaDelimiter: '',
raw: true,
attributes: [ 'id', 'content', 'important', 'date' ]
},
isNewRecord: false
}
Además de la información de la nota, en la consola se imprimen todo tipo de cosas. Podemos alcanzar el resultado deseado llamando al método modelo-objeto toJSON:
app.get('/api/notes/:id', async (req, res) => {
const note = await Note.findByPk(req.params.id)
if (note) {
console.log(note.toJSON()) res.json(note)
} else {
res.status(404).end()
}
})
Ahora el resultado es exactamente lo que queremos:
{ id: 1,
content: 'MongoDB is webscale',
important: false,
date: 2021-10-09T13:52:58.693Z }
En el caso de una colección de objetos, el método toJSON no funciona directamente, el método debe llamarse por separado para cada objeto de la colección:
router.get('/', async (req, res) => {
const notes = await Note.findAll()
console.log(notes.map(n=>n.toJSON()))
res.json(notes)
})
La impresión se parece a lo siguiente:
[ { id: 1,
content: 'MongoDB is webscale',
important: false,
date: 2021-10-09T13:52:58.693Z },
{ id: 2,
content: 'Relational databases rule the world',
important: true,
date: 2021-10-09T13:53:10.710Z } ]
Sin embargo, quizás una mejor solución sea convertir la colección en JSON para imprimir usando el método JSON.stringify:
router.get('/', async (req, res) => {
const notes = await Note.findAll()
console.log(JSON.stringify(notes))
res.json(notes)
})
Esta forma es mejor, especialmente si los objetos de la colección contienen otros objetos. También suele ser útil dar formato a los objetos en la pantalla en un formato un poco más fácil de leer. Esto se puede hacer con el siguiente comando:
console.log(JSON.stringify(notes, null, 2))
La impresión se parece a lo siguiente:
[
{
"id": 1,
"content": "MongoDB is webscale",
"important": false,
"date": "2021-10-09T13:52:58.693Z"
},
{
"id": 2,
"content": "Relational databases rule the world",
"important": true,
"date": "2021-10-09T13:53:10.710Z"
}
]