Egg.js使用Sequelize的模型关联、逻辑删除、获取器 | 8月更文挑战

3,111 阅读7分钟

这是我参与8月更文挑战的第1天,活动详情查看:8月更文挑战

sequelize.png

一、模型关联

Sequelize 提供了 四种 关联类型, 分别为:

  • HasOne 关联类型
  • BelongsTo 关联类型
  • HasMany 关联类型
  • BelongsToMany 关联类型

1. 一对一关联(HasOne、BelongsTo)

一对一是比较简单的关联关系,一般用于某张数据表的扩展表与主表之间的关联关系。例如系统中有用户表 user, 有用户的基本信息,在后面产品迭代中增加了微信登录功能,通常会增加一张微信用户扩展表 wechat_user ,来存储用户的微信信息。当我们需要微信信息的时候才去查询,这样的场景就可以对两个模型创建一对一关联。

  • 表结构及模型创建

    • user 模型

    image-20210804143407225

    // app/model/user.js
    module.exports = app => {
      const { STRING, INTEGER, DATE } = app.Sequelize;
      const User = app.model.define('user', {
        id: { type: INTEGER, primaryKey: true, autoIncrement: true, },
        nickname: STRING(20),
        password: STRING(32),
        create_time: DATE,
        update_time: DATE,
        delete_time: DATE,
      }, {
        freezeTableName: false,
        tableName: 'user',
        underscored: false,
        paranoid: true,
        timestamps: true,
        createdAt: 'create_time',
        updatedAt: false,
        deletedAt: 'delete_time',
      });
      return User;
    };
    
    • wechat_user 模型

      image-20210804143919158

    // app/model/wechat_user.js
    module.exports = app => {
      const { STRING, INTEGER, DATE } = app.Sequelize;
      const WechatUser = app.model.define('wechatUser', {
        id: { type: INTEGER, primaryKey: true, autoIncrement: true, },
        user_id: INTEGER,
        nickname: STRING,
        avatar_url: STRING,
        open_id: STRING,
        gender: INTEGER,
        create_time: DATE,
      }, {
        freezeTableName: false,
        tableName: 'wechat_user',
        underscored: false,
        timestamps: true,
        createdAt: 'create_time',
        updatedAt: false,
        deletedAt: false,
      });
      return WechatUser;
    };
    
    • 模型参数说明:

      freezeTableName : 强制表名称等于模型名称,示例中设为false,配合 tableName 直接告诉 Sequelize 表名称。

      underscored : 列字段名称是否使用驼峰转下划线。

      timestamps : 是否自动写入时间,配合 createdAt, updatedAt 使用,创建或更新内容时,这些字段都会被自动设置。

  • 创建关联关系(HasOne)

    修改 User 模型,在模型中增加如下内容,指定关联关系

    // app/model/user.js
    module.exports = app => {
    ...
    	const User = ...
    ...
      User.associate = () => {
        app.model.User.hasOne(app.model.WechatUser, {
          as: 'wechat_user',
          foreignKey: 'user_id',
          sourceKey: 'id',
        });
      };
      return User;
    }
    

    as : 别名,相当于给这个关联关系起了个名称,在查询结果中关联结果集字段名也就是这个。

    foreignKey : 自定义外键,当前关联关系相当于 user 为主表,wechat_user 为关联表,foreignKey 就是wechat_user 表中关联 user 的字段(user_id)。

    sourceKey : 主键,主表user 的主键。

  • 创建反向一对一关联关系(BelongsTo)

    修改 WechatUser 模型,指定反向关联关系

    // app/model/wechat_user.js
    module.exports = app => {
    ...
    	WechatUser.associate = () => {
        app.model.WechatUser.belongsTo(app.model.User, {
          as: 'user',
          foreignKey: 'user_id',
          targetKey: 'id',
        });
      };
    	return WechatUser;
    }
    

    反向一对一相当于还是以 user 为主表,只不过是通过 wechat_user 反向查询。

    targetKey : 目标键,反向关联的主表的主键。

  • 关联关系使用示例

    • 一对一关联

      class UserService extends Service {
        async user (id) {
          const { User, WechatUser } = this.app.model;
          return await User.findOne({
            where: { id },
            attributes: { exclude: [ 'password', 'delete_time' ], },
            include: [
              { as: 'wechat_user', model: WechatUser, }
            ]
          });
        }
      }
      

      attributes : 查询字段信息,可以通过 exclude 来指定不查询的字段,或者可以直接传入数组查询数组中的字段( attributes: ['id', 'nickname'] ) 。

      include : 指定查询的关联,as 必须和定义关联关系的 as 相同,model 指定关联的模型。

      打印 user 结果如下:

      image-20210804153355102

    • 反向一对一关联

      class UserService extends Service {
        async wechatUser (id) {
          const { WechatUser, User } = this.app.model;
          return await WechatUser.findOne({
            where: { id },
            include: [
              {
                as: 'user',
                model: User,
                attributes: { exclude: [ 'password', 'delete_time' ], },
              },
            ],
          });
        }
      }
      

      同样也可以在 include 传入各种查询参数,查询字段 attributes 、 查询条件 where 等。

      打印查询结果如下:

      image-20210804153803821

2. 一对多关联(HasMany)

一对多也是常用的关联关系,用于定义单个模型拥有多个其它模型的关联关系。例如上面的 user 表,每个用户都可以发布多篇文章,但是篇文章只能属于一个用户,用户和文章就属于一对多关系。

  • 新增文章表及模型

    image-20210804154630706

    // app/service/article.js
    const Article = app.model.define('article', {
        id: { type: INTEGER, primaryKey: true, autoIncrement: true, },
        title: STRING(255),
        content: STRING,
        user_id: INTEGER,
        create_time: DATE,
        update_time: DATE,
        delete_time: DATE,
      }, {
        freezeTableName: false,
        tableName: 'article',
        underscored: false,
        paranoid: true,
        timestamps: true,
        createdAt: 'create_time',
        updatedAt: false,
        deletedAt: 'delete_time',
      });
    
  • 创建一对多关联关系

    修改 User 模型:

    // app/model/user.js
    module.exports = app => {
    ...
    	const User = ...
    ...
      User.associate = () => {
        ...
        app.model.User.hasMany(app.model.Article, {
          as: 'articles',
          foreignKey: 'user_id',
          sourceKey: 'id',
        });
      };
      return User;
    }
    
  • 使用关联查询

    const { User, Article } = this.app.model;
    const user = await User.findOne({
      where: { id: 1 },
      attributes: { exclude: [ 'password', 'delete_time' ], },
      include: [
        { as: 'articles', model: Article, attributes: { exclude: [ 'delete_time' ], } }
      ]
    });
    

    结果打印:

    image-20210804155946937

3. 多对多关联(BelongsToMany)

多对多关联将一个源与多个目标相连,而所有这些目标又可以与第一个目标之外的其他源相连。

不能像其他关系那样通过向其中一个表添加一个外键来表示这一点. 取而代之的是使用联结模型的概念. 这将是一个额外的模型(以及数据库中的额外表),它将具有两个外键列并跟踪关联。

多对多关联关系比于一对一、一对多复杂一些,以简单的权限管理举例,一个用户可以扮演多个角色,反过来一个角色可以属于多个用户。要借助中间表建立关联关系。现有模型user ,创建新模型 role 。关联表role_user 其中包含外键 user_idrole_id

image-20210804160935931

  • 模型创建

    • role 模型

      image-20210804161610486

      module.exports = app => {
        const { STRING, INTEGER, DATE } = app.Sequelize;
        return app.model.define('role', {
          id: { type: INTEGER, primaryKey: true, autoIncrement: true, },
          name: STRING,
          create_time: DATE,
        }, {
          freezeTableName: false,
          tableName: 'role',
          timestamps: false,
        });
      };
      
    • role_user 模型

      image-20210804161737314

      module.exports = app => {
        const { INTEGER } = app.Sequelize;
        return app.model.define('role_user', {
          user_id: {
            type: INTEGER,
            references: { model: app.model.User, key: 'id', },
          },
          role_id: {
            type: INTEGER,
            references: { model: app.model.Role, key: 'id', },
          },
        }, {
          freezeTableName: false,
          tableName: 'role_user',
          underscored: true,
          timestamps: false,
        });
      };
      

      references : 给定的模型将用作联结模型,指定字段的约束。

  • 创建关联关系

    User 模型中增加如下代码:

    // app/model/user.js
    module.exports = app => {
    ...
    	const User = ...
    ...
      User.associate = () => {
        ...
        app.model.User.belongsToMany(app.model.Role, {
          as: 'user_roles',
          through: app.model.RoleUser,
          foreignKey: 'user_id',
          otherKey: 'role_id',
        });
      };
      return User;
    }
    

    如果不指定 foreignKeyotherKey, Sequelize 默认使用的外键是 userIdroleId, 要更改这些名称,Sequelize 分别接受参数 foreignKeyotherKey(即,foreignKey 定义联结关系中源模型的 key,而 otherKey 定义目标模型中的 key)。

    可以使用同样的方法在 Role 模型中创建关联关系,可以查询角色绑定的用户列表,foreignKeyotherKey 与此处相反。

  • 使用关联查询

    在service中增加方法,查询用户列表,列表中每个用户包含用户的所有角色:

    class UserService extends Service {
      async users () {
        const { User, Role } = this.app.model;
        return await User.findAll({
          attributes: {
            exclude: [ 'password', 'delete_time' ],
          },
          include: [
            { as: 'user_roles', model: Role, },
          ],
        });
      }
    }
    

    结果打印:

    image-20210804163846774

二、逻辑删除

在项目中,对数据频繁的删除操作会产生性能问题。逻辑删除作用就是在数据中增加一列字段,来标记数据是否删除,不是真正的删除,也方便必要时候的数据恢复。

在 Sequelize 中这个功能叫 Paranoid (偏执) ,这么个奇怪的名字翻了好几遍文档才找到这个功能。

以上面User 模型为例。要实现逻辑删,除必须将 paranoid: true 参数传递给模型定义。需要开启自动写入时间才能起作用(即,如果你传递 timestamps: false 了,逻辑删除将不起作用)。

可以将逻辑删除默认的字段名(默认是 deletedAt)更改为其他名称。如果不需要自动写入创建时间和更新时间要把 createdAtupdatedAt 设为false。

  • 模型定义逻辑删除
// app/model/user.js
module.exports = app => {
  const { STRING, INTEGER, DATE } = app.Sequelize;
  const User = app.model.define('user', {
    ...// 此处省略n行字段定义
  }, {
    ...
    paranoid: true,
    timestamps: true,
    createdAt: 'create_time',
    updatedAt: false,
    deletedAt: 'delete_time',
  });
  return User;
};
  • 逻辑删除的使用

    上面 User 模型设置了逻辑删除,当调用模型的 destroy() 方法就会执行 UPDATE `user` SET `delete_time`=? WHERE `id` = ? 语句,将 delete_time 设置为当前时间。查询的时候也会自动添加条件 ````delete_time` IS NULL``` 。

    class UserService extends Service {
      async deleteUser (id) {
        const { User } = this.app.model;
        const user = await User.findOne({ where: { id } });
        await user.destroy();
      }
    }
    

三、获取器、虚拟字段

1. 获取器

获取器的作用是对模型中的字段原始值做处理,输出处理后的数据。

以上面文中WechatUser 模型为例,查询之后输出时间格式为2021-08-04T05:18:32.000Z,不符合要求,希望时间输出格式为 YYYY-MM-DD HH:mm:ss ,将模型做出如下修改:

定义一个转换时间的方法,使用的 dayjs,在字段中增加 get 方法,返回格式化之后的数据。

// app/model/wechat_user.js
// 定义一个转换时间的方法
const dayjs = require('dayjs');
const formatDate = date => (date ? dayjs(date).format('YYYY-MM-DD HH:mm:ss') : date);
module.exports = app => {
  const { STRING, INTEGER, DATE } = app.Sequelize;
  const WechatUser = app.model.define('wechatUser', {
    ... // 防止干扰省略其他字段
    create_time: {
      type: DATE,
      get () {
        // 返回修改后的时间
        return formatDate(this.getDataValue('create_time'));
      },
    },
  }, {
    ... //省略配置
  });
  return WechatUser;
};

再次重新查询,发现结果变成了 "create_time": "2021-08-04 13:18:32"

2. 虚拟字段

虚拟字段是 Sequelize 在后台填充的字段,但实际上它们不存在于数据库中。

还是以WechatUser 模型为例,我们需要展示用户的性别 gender (男、女、保密) 但是还需要保留数据中的原始字段 gender ,我们可以使用Sequelize 的 DataTypes.VIRTUAL 增加一个数据表中不存在的字段 gender_text 。继续修改模型:

// app/model/wechat_user.js
// 定义性别枚举
const genderMap = { 0: '保密', 1: '男', 2: '女', };
module.exports = app => {
  const { STRING, INTEGER, DATE, VIRTUAL } = app.Sequelize;
  const WechatUser = app.model.define('wechatUser', {
    ... // 防止干扰省略其他字段
    gender: INTEGER,
    gender_text: {
      type: VIRTUAL,
      get () {
        const gender = this.getDataValue('gender');
        return genderMap[gender] ? genderMap[gender] : '';
      },
    }
  }, {
    ... //省略配置
  });
  return WechatUser;
};

这时查询,结果中就多了字段 "gender_text": "男"

image-20210804173610927