Skip to content

Node系列 · ORM:访问器和虚拟字段

Sequelize 模型字段除了最基础的"映射数据库列",还能做访问器(getter / setter)和虚拟字段(不存数据库)。前者做数据转换(脱敏、加密、JSON 解析),后者做"计算字段"(如 fullName = firstName + lastName)。

一、Getter:读取时转换

Getter 在字段被读取时自动调用——可以做脱敏、格式化、序列化。

1.1 基础用法

javascript
const { DataTypes } = require('sequelize');

const User = sequelize.define('User', {
  name: {
    type: DataTypes.STRING,
    get() {
      // this 指向原始字段值
      const raw = this.getDataValue('name');
      return raw ? raw.toUpperCase() : raw;
    },
  },
});

const user = await User.create({ name: 'alice' });
console.log(user.name);  // 'ALICE'(自动大写)

const raw = user.getDataValue('name');
console.log(raw);        // 'alice'(原始值)

1.2 实际场景:敏感字段脱敏

javascript
const User = sequelize.define('User', {
  email: {
    type: DataTypes.STRING,
    get() {
      const raw = this.getDataValue('email');
      if (!raw) return raw;
      const [name, domain] = raw.split('@');
      const masked = name[0] + '***' + (name.length > 1 ? name[name.length - 1] : '');
      return `${masked}@${domain}`;
    },
  },
});

const user = await User.create({ email: 'alice@example.com' });
console.log(user.email);  // 'a***e@example.com'

1.3 实际场景:JSON 字段自动解析

javascript
const User = sequelize.define('User', {
  preferences: {
    type: DataTypes.JSON,
    get() {
      const raw = this.getDataValue('preferences');
      return raw ? JSON.parse(raw) : {};
    },
    set(value) {
      this.setDataValue('preferences', JSON.stringify(value));
    },
  },
});

const user = await User.create({
  preferences: { theme: 'dark', lang: 'zh-CN' },
});

console.log(user.preferences);  // { theme: 'dark', lang: 'zh-CN' }(已是对象)

二、Setter:写入时处理

Setter 在字段被赋值时自动调用——可以做加密、规范化、清洗。

2.1 基础用法

javascript
const User = sequelize.define('User', {
  email: {
    type: DataTypes.STRING,
    set(value) {
      // 自动小写 + trim
      this.setDataValue('email', value.toLowerCase().trim());
    },
  },
});

2.2 实际场景:密码自动哈希

javascript
const bcrypt = require('bcrypt');

const User = sequelize.define('User', {
  password: {
    type: DataTypes.STRING,
    set(value) {
      if (!value) {
        throw new Error('密码不能为空');
      }
      const hash = bcrypt.hashSync(value, 10);
      this.setDataValue('password', hash);
    },
  },
});

const user = await User.create({ password: 'plain-password' });
console.log(user.password);  // '$2b$10$...'(自动 bcrypt 哈希)

2.3 ⚠️ Setter 与 hooks 的关系

Setter 在赋值时触发,beforeCreate hook 之前

javascript
// 时序:
// 1. user.password = 'plain'  → setter 触发 → 哈希
// 2. user.save()               → beforeCreate hook
// 3. INSERT SQL

所以在 hook 里拿到的 user.password 已经是哈希后的值——可以直接入库。

三、虚拟字段(VIRTUAL)

虚拟字段不存数据库——它是从其他字段计算出来的"派生字段"。

javascript
const User = sequelize.define('User', {
  firstName: DataTypes.STRING,
  lastName: DataTypes.STRING,
  fullName: {
    type: DataTypes.VIRTUAL,
    get() {
      return `${this.firstName} ${this.lastName}`;
    },
  },
});

const user = await User.create({ firstName: '张', lastName: '三' });
console.log(user.fullName);  // '张 三'

3.1 虚拟字段不存数据库

sql
-- 自动生成的表结构不会有 fullName 列
CREATE TABLE `users` (
  `id` INT PRIMARY KEY AUTO_INCREMENT,
  `firstName` VARCHAR(255),
  `lastName` VARCHAR(255),
  `createdAt` DATETIME,
  `updatedAt` DATETIME
);

但虚拟字段会被 JSON 序列化包含(user.toJSON() 会带 fullName),API 输出时自动包含。

3.2 虚拟字段做关联聚合

javascript
const User = sequelize.define('User', { name: DataTypes.STRING });

User.hasMany(Post, { foreignKey: 'userId' });
Post.belongsTo(User, { foreignKey: 'userId' });

User.prototype.postCount = async function () {
  return await Post.count({ where: { userId: this.id } });
};

但这种"每次访问都查库"会引发 N+1。更好的做法是用 findAllattributes.include + 聚合:

javascript
// ✅ 一次性查询,不触发 N+1
const users = await User.findAll({
  attributes: {
    include: [[Sequelize.fn('COUNT', Sequelize.col('posts.id')), 'postCount']],
  },
  include: [{ model: Post, attributes: [] }],
  group: ['User.id'],
});

users.forEach((u) => console.log(u.name, u.getDataValue('postCount')));

四、JSON 字段:实际项目最常用组合

DataTypes.JSON + 自定义 getter/setter 是实际项目里最常见的模式:

javascript
const Article = sequelize.define('Article', {
  title: DataTypes.STRING,
  // 标签数组
  tags: {
    type: DataTypes.JSON,
    get() {
      const raw = this.getDataValue('tags');
      return Array.isArray(raw) ? raw : [];
    },
    set(value) {
      // 写入时确保是数组
      if (!Array.isArray(value)) {
        throw new Error('tags 必须是数组');
      }
      this.setDataValue('tags', value);
    },
  },
  // 配置对象
  config: {
    type: DataTypes.JSON,
    get() {
      const raw = this.getDataValue('config');
      return raw || {};
    },
  },
});

const article = await Article.create({
  title: 'Hello',
  tags: ['node', 'orm'],
  config: { allowComment: true },
});

console.log(article.tags);    // ['node', 'orm']
console.log(article.config);  // { allowComment: true }

五、虚拟字段 vs 真实字段:何时用

场景推荐
派生字段(fullName、postCount)虚拟字段
频繁查询的字段(status_label)真实字段(性能更好)
敏感数据脱敏getter(按需展示)
输入清洗(trim、小写)setter
自动加密(密码哈希)setter
自动 JSON 解析getter + setter 组合

WARNING

虚拟字段不能用于 WHERE / ORDER BY——因为它不存数据库。如果一定要按派生字段排序,要么:

  1. 把字段真实化(加 allowNull: false + 迁移)
  2. 用 SQL 函数排序:order: [[Sequelize.literal('firstName || lastName'), 'ASC']]

六、与 JSON 序列化的关系

javascript
const user = await User.create({ firstName: '张', lastName: '三' });

// 直接读:虚拟字段生效
console.log(user.fullName);  // '张 三'

// toJSON:虚拟字段会包含
console.log(user.toJSON());
// { id: 1, firstName: '张', lastName: '三', fullName: '张 三', createdAt: ... }

// toJSON 也触发 getter
console.log(user.toJSON().email);  // 'a***e@example.com'

七、最佳实践

场景推荐
字段脱敏用 getter;保留原始值用 getDataValue
输入清洗用 setter(小写、trim、去除危险字符)
密码哈希setter(hashSync)或 beforeCreate hook
JSON 字段getter + setter 组合,自动 parse / stringify
派生字段虚拟字段;但不用于 WHERE
频繁查询的派生真实化字段 + 维护逻辑
关联计数attributes.include + fn('COUNT'),不要用 prototype 方法

八、小结

  • Getter 在读取时转换:脱敏、格式化、自动 JSON 解析
  • Setter 在写入时处理:清洗、加密、规范化
  • 虚拟字段DataTypes.VIRTUAL):不存数据库,按需从其他字段计算
  • 虚拟字段会包含在 toJSON() 里,但不能用于 WHERE / ORDER BY
  • 关联计数用 attributes.include + fn('COUNT'),避免 N+1
  • getter 内部用 getDataValue 取原始值;setter 内部用 setDataValue 存值
  • 密码哈希用 setter 或 beforeCreate hook——两者都在 INSERT 前生效