Saltar al contenido

c

Migraciones, relaciones de muchos-a-muchos

Migraciones

Sigamos ampliando el backend. Queremos implementar soporte para permitir que los usuarios con estado de administrador pongan a los usuarios de su elección en modo deshabilitado, evitando que inicien sesión y creen nuevas notas. Para implementar esto, necesitamos agregar campos booleanos a la tabla de la base de datos de los usuarios que indiquen si el usuario es un administrador y si el usuario está deshabilitado.

Podríamos proceder como antes, es decir, cambiar el modelo que define la tabla y confiar en Sequelize para sincronizar los cambios en la base de datos. Esto se especifica mediante estas líneas en el archivo models/index.js

const Note = require('./note')
const User = require('./user')

Note.belongsTo(User)
User.hasMany(Note)

Note.sync({ alter: true })User.sync({ alter: true })
module.exports = {
  Note, User
}

Sin embargo, este enfoque no tiene sentido a largo plazo. Eliminemos las líneas que realizan la sincronización y pasemos a usar una forma mucho más robusta, migraciones proporcionada por Sequelize (y muchas otras bibliotecas).

En la práctica, una migración es un único archivo JavaScript que describe alguna modificación en una base de datos. Se crea un archivo de migración independiente para cada uno o varios cambios a la vez. Sequelize mantiene un registro de las migraciones que se han realizado, es decir, qué cambios provocados por las migraciones se sincronizan con el esquema de la base de datos. Al crear nuevas migraciones, Sequelize se mantiene actualizado sobre los cambios que aún deben realizarse en el esquema de la base de datos. De esta forma, los cambios se realizan de forma controlada, con el código del programa almacenado en el control de versiones.

Primero, creemos una migración que inicialice la base de datos. El código para la migración es el siguiente:

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.createTable('notes', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      content: {
        type: DataTypes.TEXT,
        allowNull: false
      },
      important: {
        type: DataTypes.BOOLEAN
      },
      date: {
        type: DataTypes.DATE
      },
    })
    await queryInterface.createTable('users', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      username: {
        type: DataTypes.STRING,
        unique: true,
        allowNull: false
      },
      name: {
        type: DataTypes.STRING,
        allowNull: false
      },
    })
    await queryInterface.addColumn('notes', 'user_id', {
      type: DataTypes.INTEGER,
      allowNull: false,
      references: { model: 'users', key: 'id' },
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.dropTable('notes')
    await queryInterface.dropTable('users')
  },
}

El archivo de migración define las funciones up y down, la primera de que define cómo se debe modificar la base de datos cuando se realiza la migración. La función down le indica cómo deshacer la migración si es necesario.

Nuestra migración contiene tres operaciones, la primera crea una tabla de notes, la segunda crea una tabla de users y la tercera agrega una clave externa a las notes que hace referencia al creador de la nota. Los cambios en el esquema se definen llamando a los métodos de objeto queryInterface.

Al definir las migraciones, es esencial recordar que, a diferencia de los modelos, los nombres de columnas y tablas se escriben en forma de serpiente (snake case):

await queryInterface.addColumn('notes', 'user_id', {  type: DataTypes.INTEGER,
  allowNull: false,
  references: { model: 'users', key: 'id' },
})

Por lo tanto, en las migraciones, los nombres de las tablas y las columnas se escriben exactamente como aparecen en la base de datos, mientras que los modelos usan la convención de nomenclatura camelCase predeterminada de Sequelize.

Guarde el código de migración en el archivo migrations/20211209_00_initialize_notes_and_users.js. Los nombres de los archivos de migración siempre deben nombrarse alfabéticamente cuando se crean para que los cambios anteriores estén siempre antes de los cambios más nuevos. Una buena forma de lograr este orden es comenzar el nombre del archivo de migración con la fecha y un número de secuencia.

Podríamos ejecutar las migraciones desde la línea de comandos usando la herramienta de línea de comandos Sequelize. Sin embargo, elegimos realizar las migraciones manualmente desde el código del programa utilizando la biblioteca Umzug. Instalamos la biblioteca:

npm install umzug

Cambiemos el archivo util/db.js que maneja la conexión a la base de datos de la siguiente manera:

const Sequelize = require('sequelize')
const { DATABASE_URL } = require('./config')
const { Umzug, SequelizeStorage } = require('umzug')
const sequelize = new Sequelize(DATABASE_URL, {
  dialectOptions: {
    ssl: {
      require: true,
      rejectUnauthorized: false
    }
  },
});

const runMigrations = async () => {  const migrator = new Umzug({    migrations: {      glob: 'migrations/*.js',    },    storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }),    context: sequelize.getQueryInterface(),    logger: console,  })    const migrations = await migrator.up()  console.log('Migrations up to date', {    files: migrations.map((mig) => mig.name),  })}
const connectToDatabase = async () => {
  try {
    await sequelize.authenticate()
    await runMigrations()    console.log('connected to the database')
  } catch (err) {
    console.log('failed to connect to the database')
    console.log(err)
    return process.exit(1)
  }

  return null
}

module.exports = { connectToDatabase, sequelize }

La función runMigrations que realiza migraciones ahora se ejecuta cada vez que la aplicación abre una conexión de base de datos cuando se inicia. Sequelize realiza un seguimiento de las migraciones que ya se han completado, por lo que si no hay nuevas migraciones, ejecutar la función runMigrations no hace nada.

Ahora comencemos con una pizarra limpia y eliminemos todas las tablas de base de datos existentes de la aplicación:

username => drop table notes;
username => drop table users;
username => \d
Did not find any relations.

Iniciemos la aplicación. Se imprime un mensaje sobre el estado de las migraciones en el registro.

INSERT INTO "migrations" ("name") VALUES ($1) RETURNING "name";
Migrations up to date { files: [ '20211209_00_initialize_notes_and_users.js' ] }
database connected

Si reiniciamos la aplicación, el registro también muestra que la migración no se repitió.

El esquema de la base de datos de la aplicación ahora se ve así:

postgres=# \d
                 List of relations
 Schema |     Name     |   Type   |     Owner
--------+--------------+----------+----------------
 public | migrations   | table    | username
 public | notes        | table    | username
 public | notes_id_seq | sequence | username
 public | users        | table    | username
 public | users_id_seq | sequence | username

Entonces, Sequelize ha creado una tabla migrations que le permite realizar un seguimiento de las migraciones que se han realizado. El contenido de la tabla queda de la siguiente manera:

postgres=# select * from migrations;
                   name
-------------------------------------------
 20211209_00_initialize_notes_and_users.js

Vamos a crear algunos usuarios en la base de datos, así como un conjunto de notas, y luego estamos listos para expandir la aplicación.

El código actual de la aplicación se encuentra en su totalidad en GitHub, rama part13-6.

Usuario administrador y usuario deshabilitado

Entonces queremos agregar dos campos booleanos a la tabla users

  • admin te dice si el usuario es un administrador
  • disabled te dice si el usuario está deshabilitado de las acciones

Vamos a crear la migración que modifica la base de datos en el archivo migrations/20211209_01_admin_and_disabled_to_users.js:

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.addColumn('users', 'admin', {
      type: DataTypes.BOOLEAN,
      default: false
    })
    await queryInterface.addColumn('users', 'disabled', {
      type: DataTypes.BOOLEAN,
      default: false
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.removeColumn('users', 'admin')
    await queryInterface.removeColumn('users', 'disabled')
  },
}

Realice los cambios correspondientes en el modelo correspondiente a la tabla users:

User.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  username: {
    type: DataTypes.STRING,
    unique: true,
    allowNull: false
  },
  name: {
    type: DataTypes.STRING,
    allowNull: false
  },
  admin: {    type: DataTypes.BOOLEAN,    defaultValue: false  },  disabled: {    type: DataTypes.BOOLEAN,    defaultValue: false  },}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'user'
})

Cuando se realiza la nueva migración al reiniciar el código, el esquema se cambia a lo siguiente:

username-> \d users
                                     Table "public.users"
  Column  |          Type          | Collation | Nullable |              Default
----------+------------------------+-----------+----------+-----------------------------------
 id       | integer                |           | not null | nextval('users_id_seq'::regclass)
 username | character varying(255) |           | not null |
 name     | character varying(255) |           | not null |
 admin    | boolean                |           |          |
 disabled | boolean                |           |          |
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)
    "users_username_key" UNIQUE CONSTRAINT, btree (username)
Referenced by:
    TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id)

Ahora vamos a expandir los controladores de la siguiente manera. Evitamos iniciar sesión si el campo de usuario disabled está establecido en true:

loginRouter.post('/', async (request, response) => {
  const body = request.body

  const user = await User.findOne({
    where: {
      username: body.username
    }
  })

  const passwordCorrect = body.password === 'secret'

  if (!(user && passwordCorrect)) {
    return response.status(401).json({
      error: 'invalid username or password'
    })
  }

  if (user.disabled) {    return response.status(401).json({      error: 'account disabled, please contact admin'    })  }
  const userForToken = {
    username: user.username,
    id: user.id,
  }

  const token = jwt.sign(userForToken, process.env.SECRET)

  response
    .status(200)
    .send({ token, username: user.username, name: user.name })
})

Desactivemos al usuario jakousa usando su ID:

username => update users set disabled=true where id=3;
UPDATE 1
username => update users set admin=true where id=1;
UPDATE 1
username => select * from users;
 id | username |       name       | admin | disabled
----+----------+------------------+-------+----------
  2 | lynx     | Kalle Ilves      |       |
  3 | jakousa  | Jami Kousa       | f     | t
  1 | mluukkai | Matti Luukkainen | t     |

Y asegúrese de que ya no sea posible iniciar sesión

fullstack content

Vamos a crear una ruta que permita a un administrador cambiar el estado de la cuenta de un usuario:

const isAdmin = async (req, res, next) => {
  const user = await User.findByPk(req.decodedToken.id)
  if (!user.admin) {
    return res.status(401).json({ error: 'operation not allowed' })
  }
  next()
}

router.put('/:username', tokenExtractor, isAdmin, async (req, res) => {
  const user = await User.findOne({
    where: {
      username: req.params.username
    }
  })

  if (user) {
    user.disabled = req.body.disabled
    await user.save()
    res.json(user)
  } else {
    res.status(404).end()
  }
})

Se utilizan dos middleware, el primero llamado tokenExtractor es el mismo que el utilizado por la ruta de creación de notas, es decir, coloca el token decodificado en el campo decodedToken del objeto request. El segundo middleware isAdmin comprueba si el usuario es un administrador y, en caso contrario, el estado de la solicitud se establece en 401 y se devuelve el mensaje de error correspondiente.

Observe cómo dos middleware están encadenados a la ruta, los cuales se ejecutan antes que el controlador de ruta real. Es posible encadenar un número arbitrario de middleware a una solicitud.

El middleware tokenExtractor ahora se ha movido a util/middleware.js ya que se usa desde varias ubicaciones.

const jwt = require('jsonwebtoken')
const { SECRET } = require('./config.js')

const tokenExtractor = (req, res, next) => {
  const authorization = req.get('authorization')
  if (authorization && authorization.toLowerCase().startsWith('bearer ')) {
    try {
      req.decodedToken = jwt.verify(authorization.substring(7), SECRET)
    } catch{
      return res.status(401).json({ error: 'token invalid' })
    }
  } else {
    return res.status(401).json({ error: 'token missing' })
  }
  next()
}

module.exports = { tokenExtractor }

Ahora, un administrador puede volver a habilitar al usuario jakousa realizando una solicitud PUT a /api/users/jakousa, donde la solicitud incluye los siguientes datos:

{
    "disabled": false
}

Como se señaló en el final de la Parte 4, la forma en que implementamos la desactivación de usuarios aquí es problemática. Si el usuario está deshabilitado o no, solo se verifica en login, si el usuario tiene un token en el momento en que se deshabilita, el usuario puede continuar usando el mismo token, ya que no se ha establecido una vida útil para el token y el estado deshabilitado. El usuario no se comprueba al crear notas.

Antes de continuar, hagamos un script npm para la aplicación, que nos permita deshacer la migración anterior. Después de todo, no todo sale bien la primera vez cuando se desarrollan migraciones.

Modifiquemos el archivo util/db.js de la siguiente manera:

const Sequelize = require('sequelize')
const { DATABASE_URL } = require('./config')
const { Umzug, SequelizeStorage } = require('umzug')

const sequelize = new Sequelize(DATABASE_URL, {
  dialectOptions: {
    ssl: {
      require: true,
      rejectUnauthorized: false
    }
  },
});

const connectToDatabase = async () => {
  try {
    await sequelize.authenticate()
    await runMigrations()
    console.log('connected to the database')
  } catch (err) {
    console.log('failed to connect to the database')
    return process.exit(1)
  }

  return null
}

const migrationConf = {  migrations: {    glob: 'migrations/*.js',  },  storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }),  context: sequelize.getQueryInterface(),  logger: console,}  const runMigrations = async () => {  const migrator = new Umzug(migrationConf)  const migrations = await migrator.up()  console.log('Migrations up to date', {    files: migrations.map((mig) => mig.name),  })}const rollbackMigration = async () => {  await sequelize.authenticate()  const migrator = new Umzug(migrationConf)  await migrator.down()}
module.exports = { connectToDatabase, sequelize, rollbackMigration }

Vamos a crear un archivo util/rollback.js, que permitirá que el script npm ejecute la función de reversión de migración especificada:

const { rollbackMigration } = require('./db')

rollbackMigration()

y el script en sí:

{
  "scripts": {
    "dev": "nodemon index.js",
    "migration:down": "node util/rollback.js"  },
}

Así que ahora podemos deshacer la migración anterior ejecutando npm run migration:down desde la línea de comandos.

Actualmente, las migraciones se ejecutan automáticamente cuando se inicia el programa. En la fase de desarrollo del programa, en ocasiones puede ser más adecuado deshabilitar la ejecución automática de migraciones y realizarlas manualmente desde la línea de comandos.

El código actual de la aplicación se encuentra en su totalidad en GitHub, rama part13-7.

Relaciones de muchos a muchos

Continuaremos expandiendo la aplicación para que cada usuario pueda agregarse a uno o más equipos.

Dado que una cantidad arbitraria de usuarios puede unirse a un equipo y un usuario puede unirse a una cantidad arbitraria de equipos, estamos tratando con muchos a muchos, que tradicionalmente se implementa en bases de datos relacionales mediante una tabla de conexiones.

Ahora vamos a crear el código necesario para la tabla teams y la tabla memberships. La migración es la siguiente:

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.createTable('teams', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      name: {
        type: DataTypes.TEXT,
        allowNull: false,
        unique: true
      },
    })
    await queryInterface.createTable('memberships', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      user_id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        references: { model: 'users', key: 'id' },
      },
      team_id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        references: { model: 'teams', key: 'id' },
      },
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.dropTable('teams')
    await queryInterface.dropTable('memberships')
  },
}

Los modelos contienen casi el mismo código que la migración. El modelo de equipo en models/team.js:

const { Model, DataTypes } = require('sequelize')

const { sequelize } = require('../util/db')

class Team extends Model {}

Team.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  name: {
    type: DataTypes.TEXT,
    allowNull: false,
    unique: true
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'team'
})

module.exports = Team

El modelo para la tabla de conexiones en models/membership.js:

const { Model, DataTypes } = require('sequelize')

const { sequelize } = require('../util/db')

class Membership extends Model {}

Membership.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  user_id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  },
  team_id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'teams', key: 'id' },
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'membership'
})

module.exports = Membership

Así que le hemos dado a la tabla de conexiones un nombre que la describe bien, membership. No siempre hay un nombre relevante para una tabla de conexión, en cuyo caso el nombre de la tabla de conexión puede ser una combinación de los nombres de las tablas que se unen, p. user_teams podría encajar en nuestra situación.

Realizamos una pequeña adición al archivo models/index.js para conectar equipos y usuarios a nivel de código mediante el método belongsToMany.

const Note = require('./note')
const User = require('./user')
const Team = require('./team')const Membership = require('./membership')
Note.belongsTo(User)
User.hasMany(Note)

User.belongsToMany(Team, { through: Membership })Team.belongsToMany(User, { through: Membership })
module.exports = {
  Note, User, Team, Membership}

Tenga en cuenta la diferencia entre la migración de la tabla de conexión y el modelo al definir campos de clave externa. Durante la migración, los campos se definen en forma de mayúsculas y minúsculas:

await queryInterface.createTable('memberships', {
  // ...
  user_id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  },
  team_id: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'teams', key: 'id' },
  }
})

en el modelo, los mismos campos se definen en camel case:

Membership.init({
  // ...
  userId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  },
  teamId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'teams', key: 'id' },
  },
  // ...
})

Ahora vamos a crear un par de equipos desde la consola, así como algunas membresías:

insert into teams (name) values ('toska');
insert into teams (name) values ('mosa climbers');
insert into memberships (user_id, team_id) values (1, 1);
insert into memberships (user_id, team_id) values (1, 2);
insert into memberships (user_id, team_id) values (2, 1);
insert into memberships (user_id, team_id) values (3, 2);

Luego, se agrega información sobre los equipos de los usuarios a la ruta para recuperar a todos los usuarios.

router.get('/', async (req, res) => {
  const users = await User.findAll({
    include: [
      {
        model: Note,
        attributes: { exclude: ['userId'] }
      },
      {        model: Team,        attributes: ['name', 'id'],      }    ]
  })
  res.json(users)
})

Los más observadores notarán que la consulta impresa en la consola ahora combina tres tablas.

La solución es bastante buena, pero tiene un hermoso defecto. El resultado también viene con los atributos de la fila correspondiente de la tabla de conexiones, aunque no queremos esto:

fullstack content

Si lee detenidamente la documentación, puede encontrar una solución:

router.get('/', async (req, res) => {
  const users = await User.findAll({
    include: [
      {
        model: Note,
        attributes: { exclude: ['userId'] }
      },
      {
        model: Team,
        attributes: ['name', 'id'],
        through: {          attributes: []        }      }
    ]
  })
  res.json(users)
})

El código actual de la aplicación se encuentra en su totalidad en GitHub, rama part13-8.

Nota sobre las propiedades de los objetos del modelo Sequelize

La especificación de nuestros modelos se muestra en las siguientes líneas:

User.hasMany(Note)
Note.belongsTo(User)

User.belongsToMany(Team, { through: Membership })
Team.belongsToMany(User, { through: Membership })

Estos permiten a Sequelize realizar consultas que recuperan, por ejemplo, todas las notas de los usuarios o de todos los miembros de un equipo.

Gracias a las definiciones, también tenemos acceso directo a, por ejemplo, las notas del usuario en el código. En el siguiente código, buscaremos un usuario con id 1 e imprimiremos las notas asociadas con el usuario:

const user = await User.findByPk(1, {
  include: {
    model: Note
  }
})

user.notes.forEach(note => {
  console.log(note.content)
})

Por lo tanto, la definición User.hasMany(Note) adjunta una propiedad notes al objeto user, que da acceso a las notas realizadas por el usuario. De manera similar, la definición User.belongsToMany(Team, { through: Membership })) adjunta una propiedad teams al objeto user, que también puede utilizarse en el código:

const user = await User.findByPk(1, {
  include: {
    model: team
  }
})

user.teams.forEach(team => {
  console.log(team.name)
})

Supongamos que nos gustaría devolver un objeto JSON de la ruta del usuario único que contiene el nombre del usuario, el nombre de usuario y la cantidad de notas creadas. Podríamos intentar lo siguiente:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    include: {
        model: Note
      }
    }
  )

  if (user) {
    user.note_count = user.notes.length    delete user.notes    res.json(user)

  } else {
    res.status(404).end()
  }
})

Entonces, intentamos agregar el campo noteCount en el objeto devuelto por Sequelize y eliminar el campo notes de él. Sin embargo, este enfoque no funciona, ya que los objetos devueltos por Sequelize no son objetos normales donde la adición de nuevos campos funciona como pretendemos.

Una mejor solución es crear un objeto completamente nuevo basado en los datos recuperados de la base de datos:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    include: {
        model: Note
      }
    }
  )

  if (user) {
    res.json({
      username: user.username,      name: user.name,      note_count: user.notes.length    })

  } else {
    res.status(404).end()
  }
})

Revisando las relaciones de muchos a muchos

Hagamos otra relación de muchos a muchos en la aplicación. Cada nota está asociada al usuario que la creó mediante una clave foránea. Ahora se decide que la aplicación también admite que la nota se pueda asociar con otros usuarios y que un usuario se pueda asociar con un número arbitrario de notas creadas por otros usuarios. La idea es que estas notas sean las que el usuario ha marcado para sí mismo.

Hagamos una tabla de conexión user_notes para la situación. La migración es sencilla:

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.createTable('user_notes', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      user_id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        references: { model: 'users', key: 'id' },
      },
      note_id: {
        type: DataTypes.INTEGER,
        allowNull: false,
        references: { model: 'notes', key: 'id' },
      },
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.dropTable('user_notes')
  },
}

Además, no hay nada especial en el modelo:

const { Model, DataTypes } = require('sequelize')

const { sequelize } = require('../util/db')

class UserNotes extends Model {}

UserNotes.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  userId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'users', key: 'id' },
  },
  noteId: {
    type: DataTypes.INTEGER,
    allowNull: false,
    references: { model: 'notes', key: 'id' },
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'user_notes'
})

module.exports = UserNotes

El archivo models/index.js, por otro lado, viene con un ligero cambio:

const Note = require('./note')
const User = require('./user')
const Team = require('./team')
const Membership = require('./membership')
const UserNotes = require('./user_notes')
Note.belongsTo(User)
User.hasMany(Note)

User.belongsToMany(Team, { through: Membership })
Team.belongsToMany(User, { through: Membership })

User.belongsToMany(Note, { through: UserNotes, as: 'marked_notes' })Note.belongsToMany(User, { through: UserNotes, as: 'users_marked' })
module.exports = {
  Note, User, Team, Membership, UserNotes
}

Una vez más se utiliza belongsToMany, que ahora vincula a los usuarios con las notas a través del modelo UserNotes correspondiente a la tabla de conexiones. Sin embargo, esta vez damos un nombre de alias para el atributo formado usando la palabra clave as, el nombre predeterminado (las notes de un usuario) se superpondría con su significado anterior, es decir, notas creadas por el usuario.

Extendemos la ruta para que un usuario individual devuelva los equipos del usuario, sus propias notas y otras notas marcadas por el usuario:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    attributes: { exclude: [''] } ,
    include:[{
        model: Note,
        attributes: { exclude: ['userId'] }
      },
      {
        model: Note,
        as: 'marked_notes',
        attributes: { exclude: ['userId']},
        through: {
          attributes: []
        }
      },
      {
        model: Team,
        attributes: ['name', 'id'],
        through: {
          attributes: []
        }
      },
    ]
  })

  if (user) {
    res.json(user)
  } else {
    res.status(404).end()
  }
})

En el contexto del include, ahora debemos usar el nombre de alias marked_notes que acabamos de definir con el atributo as.

Para probar la característica, vamos a crear algunos datos de prueba en la base de datos:

insert into user_notes (user_id, note_id) values (1, 4);
insert into user_notes (user_id, note_id) values (1, 5);

El resultado final es funcional:

fullstack content

¿Y si quisiéramos incluir información sobre el autor de la nota en las notas marcadas por el usuario también? Esto se puede hacer agregando un include a las notas marcadas:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    attributes: { exclude: [''] } ,
    include:[{
        model: Note,
        attributes: { exclude: ['userId'] }
      },
      {
        model: Note,
        as: 'marked_notes',
        attributes: { exclude: ['userId']},
        through: {
          attributes: []
        },
        include: {          model: User,          attributes: ['name']        }      },
      {
        model: Team,
        attributes: ['name', 'id'],
        through: {
          attributes: []
        }
      },
    ]
  })

  if (user) {
    res.json(user)
  } else {
    res.status(404).end()
  }
})

El resultado final es el deseado:

fullstack content

El código actual de la aplicación se encuentra en su totalidad en GitHub, rama part13-9.

Observaciones finales

El estado de nuestra aplicación empieza a ser al menos aceptable. Sin embargo, antes del final de la sección, veamos algunos puntos más.

Eager vs lazy fetch

Cuando hacemos consultas usando el atributo include:

User.findOne({
  include: {
    model: note
  }
})

Se produce la llamada eager fetch, es decir, todas las filas de las tablas adjuntas al usuario por el consulta de unión, en el ejemplo, las notas hechas por el usuario, se obtienen de la base de datos al mismo tiempo. A menudo, esto es lo que queremos, pero también hay situaciones en las que desea hacer lo que se conoce como lazy fetch, p. Busque equipos relacionados con el usuario solo si son necesarios.

Ahora modifiquemos la ruta para un usuario individual, para que obtenga los equipos del usuario solo si el parámetro de consulta teams está configurado en la solicitud:

router.get('/:id', async (req, res) => {
  const user = await User.findByPk(req.params.id, {
    attributes: { exclude: [''] } ,
    include:[{
        model: note,
        attributes: { exclude: ['userId'] }
      },
      {
        model: Note,
        as: 'marked_notes',
        attributes: { exclude: ['userId']},
        through: {
          attributes: []
        },
        include: {
          model: user,
          attributes: ['name']
        }
      },
    ]
  })

  if (!user) {
    return res.status(404).end()
  }

  let teams = undefined  if (req.query.teams) {    teams = await user.getTeams({      attributes: ['name'],      joinTableAttributes: []      })  }  res.json({ ...user.toJSON(), teams })})

Ahora, la consulta User.findByPk no recupera equipos, pero se recuperan si es necesario mediante el método user getTeams, que se genera automáticamente por Sequelize para el objeto modelo. Igualmente get y algunos otros métodos útiles se generan automáticamente al definir asociaciones para tablas a nivel de Sequelize.

Características de los modelos

Hay algunas situaciones en las que, por defecto, no queremos manejar todas las filas de una tabla en particular. Uno de esos casos podría ser que normalmente no queremos mostrar usuarios que han sido deshabilitados en nuestra aplicación. En tal situación, podríamos definir los [ámbitos] predeterminados (https://sequelize.org/master/manual/scopes.html) para el modelo de esta manera:

class User extends Model {}

User.init({
  // field definition
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'user',
  defaultScope: {    where: {      disabled: false    }  },  scopes: {    admin: {      where: {        admin: true      }    },    disabled: {      where: {        disabled: true      }    }  }})

module.exports = User

Ahora la consulta causada por la llamada a la función User.findAll() tiene la siguiente condición WHERE:

WHERE "user". "disabled" = false;

Para los modelos, también es posible definir otros alcances:

User.init({
  // field definition
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'user',
  defaultScope: {
    where: {
      disabled: false
    }
  },
  scopes: {    admin: {      where: {        admin: true      }    },    disabled: {      where: {        disabled: true      }    },    name(value) {      return {        where: {          name: {            [Op.iLike]: value          }        }      }    },  }})

Los alcances se utilizan de la siguiente manera:

// all admins
const adminUsers = await User.scope('admin').findAll()

// all inactive users
const disabledUsers = await User.scope('disabled').findAll()

// users with the string jami in their name
const jamiUsers = User.scope({ method: ['name', '%jami%'] }).findAll()

También es posible encadenar ámbitos:

// admins with the string jami in their name
const jamiUsers = User.scope('admin', { method: ['name', '%jami%'] }).findAll()

Dado que los modelos de Sequelize son clases de JavaScript, es posible agregarles nuevos métodos.

Aquí hay dos ejemplos:

const { Model, DataTypes, Op } = require('sequelize')
const Note = require('./note')
const { sequelize } = require('../util/db')

class User extends Model {
  async number_of_notes() {    return (await this.getNotes()).length  }  static async with_notes(limit){    return await User.findAll({      attributes: {        include: [[ sequelize.fn("COUNT", sequelize.col("notes.id")), "note_count" ]]      },      include: [        {          model: Note,          attributes: []        },      ],      group: ['user.id'],      having: sequelize.literal(`COUNT(notes.id) > ${limit}`)    })  }}

User.init({
  // ...
})

module.exports = User

El primero de los métodos numberOfNotes es un método de instancia, lo que significa que se puede llamar en instancias del modelo:

const jami = await User.findOne({ name: 'Jami Kousa'})
const cnt = await jami.number_of_notes()
console.log(`Jami has created ${cnt} notes`)

Dentro del método de instancia, la palabra clave this se refiere a la instancia misma:

async number_of_notes() {
  return (await this.getNotes()).length
}

El segundo de los métodos, que devuelve aquellos usuarios que tienen al menos X cantidad de notas es un método de clase, es decir, se llama directamente en el modelo:

const users = await User.with_notes(2)
console.log(JSON.stringify(users, null, 2))
users.forEach(u => {
  console.log(u.name)
})

Repetibilidad de modelos y migraciones

Hemos notado que el código para modelos y migraciones es muy repetitivo. Por ejemplo, el modelo de equipos

class Team extends Model {}

Team.init({
  id: {
    type: DataTypes.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  name: {
    type: DataTypes.TEXT,
    allowNull: false,
    unique: true
  },
}, {
  sequelize,
  underscored: true,
  timestamps: false,
  modelName: 'team'
})

module.exports = Team

y la migración contienen gran parte del mismo código

const { DataTypes } = require('sequelize')

module.exports = {
  up: async ({ context: queryInterface }) => {
    await queryInterface.createTable('teams', {
      id: {
        type: DataTypes.INTEGER,
        primaryKey: true,
        autoIncrement: true
      },
      name: {
        type: DataTypes.TEXT,
        allowNull: false,
        unique: true
      },
    })
  },
  down: async ({ context: queryInterface }) => {
    await queryInterface.dropTable('teams')
  },
}

¿No podríamos optimizar el código para que, por ejemplo, el modelo exporte las partes compartidas necesarias para la migración?

Sin embargo, el problema es que la definición del modelo puede cambiar con el tiempo, por ejemplo, el campo name puede cambiar o su tipo de datos puede cambiar. Las migraciones deben poder realizarse correctamente en cualquier momento de principio a fin, y si las migraciones dependen del modelo para tener cierto contenido, es posible que ya no sea cierto en un mes o un año. Por lo tanto, a pesar del "copiar y pegar", el código de migración debe estar completamente separado del código del modelo.

Una solución sería usar la herramienta de línea de comandos de Sequelize , que genera modelos y migración archivos basados ​​en comandos dados en la línea de comandos. Por ejemplo, el siguiente comando crearía un modelo Users con name, username y admin como atributos, como así como la migración que gestiona la creación de la tabla de la base de datos:

npx sequelize-cli model:generate --name User --attributes name:string,username:string,admin:boolean

Desde la línea de comandos, también puede ejecutar reversiones, es decir, deshacer migraciones. Desafortunadamente, la documentación de la línea de comandos está incompleta y en este curso decidimos hacer los modelos y las migraciones manualmente. La solución puede o no haber sido sabia.