Model API 文档

Model 层提供 Schema 验证、自定义方法和生命周期钩子,让你像使用 ORM 一样使用 monSQLize。

特性:Schema 验证 · 自定义方法 · 生命周期钩子 · 自动索引 · 数据源绑定(v1.2.2+)


快速开始

import MonSQLize from 'monsqlize';
const { Model } = MonSQLize;

// 1. 定义 Model
Model.define('users', {
    schema: (dsl) => dsl({
        username: 'string:3-32!',
        email: 'email!',
        password: 'string!',
        age: 'number:0-120'
    }),
    methods: (model) => ({
        instance: {
            checkPassword(password) {
                return this.password === password;
            }
        },
        static: {
            async findByUsername(username) {
                return await model.findOne({ username });
            }
        }
    })
});

// 2. 使用 Model
const msq = new MonSQLize({ ... });
await msq.connect();
const User = msq.model('users');

// 插入
await User.insertOne({
    username: 'test',
    email: 'test@example.com',
    password: 'secret123',
    age: 25
});

// 查询并使用方法
const user = await User.findByUsername('test');
if (user.checkPassword('secret123')) {
    console.log('登录成功');
}

API 参考

Model.define(collectionName, definition)

注册 Model 定义。

参数

  • collectionName - 集合名称
  • definition - Model 定义
    • schema (必需) - Schema 定义
    • enums - 枚举配置
    • methods - 自定义方法
    • hooks - 生命周期钩子
    • indexes - 索引定义
    • connection - 数据源绑定(v1.2.2+,可选)
      • pool - 连接池名称,须与构造函数 pools[].name 一致
      • database - 数据库名称,不填则使用实例 databaseName
Model.define('users', {
    enums: {
        role: 'admin|user|guest'
    },
    schema: function(dsl) {
        return dsl({
            username: 'string:3-32!',
            email: 'email!',
            password: 'string!',
            role: this.enums.role.default('user')
        });
    },
    methods: (model) => ({
        instance: {
            checkPassword(password) {
                return this.password === password;
            }
        },
        static: {
            async findByUsername(username) {
                return await model.findOne({ username });
            }
        }
    }),
    hooks: (model) => ({
        insert: {
            before: async (ctx, docs) => {
                return { ...docs, createdAt: new Date() };
            }
        }
    }),
    indexes: [
        { key: { username: 1 }, unique: true }
    ]
});

Model.get(collectionName)

获取已注册的 Model 定义。

参数

  • collectionName (string) - 集合名称

返回{ collectionName: string, definition: object } | undefined

返回包含 collectionNamedefinition 的包装对象。如果指定的 Model 未注册,则返回 undefined

// 获取已注册的 Model 定义
const userModel = Model.get('users');

if (userModel) {
    console.log(userModel.collectionName); // 'users'
    console.log(userModel.definition);     // { schema: ..., methods: ..., ... }
}

// 未注册的 Model 返回 undefined
const notFound = Model.get('nonexistent');
console.log(notFound); // undefined

Model.has(collectionName)

检查 Model 是否已注册。

参数

  • collectionName (string) - 集合名称

返回boolean

// 检查 Model 是否已注册
if (Model.has('users')) {
    console.log('users Model 已注册');
}

// 条件注册:避免重复定义
if (!Model.has('users')) {
    Model.define('users', {
        schema: (dsl) => dsl({ username: 'string!' })
    });
}

Model.list()

获取所有已注册的 Model 名称。

无参数

返回string[] - 所有已注册 Model 的集合名称数组

// 获取所有已注册的 Model 名称
const models = Model.list();
console.log(models); // ['users', 'posts', 'comments']

// 遍历所有已注册的 Model
for (const name of Model.list()) {
    const model = Model.get(name);
    console.log(`${name}:`, model.definition);
}

Model.redefine(collectionName, definition)(v1.1.7+)

重新定义已注册的 Model。等效于 undefine() + define() 的组合操作。

参数

  • collectionName (string) - 集合名称
  • definition (object) - 新的 Model 定义

返回:void

如果 Model 不存在,行为等同于 define()

⚠️ 注意:如果新定义校验失败,旧定义会被移除(不会回滚)。主要用于开发模式下的 Model 热重载。

可抛出Error(与 define() 相同的校验逻辑)

// 重新定义已注册的 Model
Model.redefine('users', {
    schema: (dsl) => dsl({
        username: 'string:3-32!',
        email: 'email!',
        avatar: 'string'   // 新增字段
    })
});

// 开发模式下的热重载示例
if (process.env.NODE_ENV === 'development') {
    Model.redefine('users', updatedDefinition);
}

Model.undefine(collectionName)(v1.1.7+)

注销已注册的 Model 定义。幂等操作,对不存在的 Model 不抛错。

参数

  • collectionName (string) - 要注销的集合名称

返回boolean - 成功移除返回 true,不存在返回 false

已实例化的 ModelInstance 不受影响。主要用于开发模式下的 Model 热重载。

// 注销已注册的 Model
const removed = Model.undefine('users');
console.log(removed); // true

// 对不存在的 Model 不抛错
const notFound = Model.undefine('nonexistent');
console.log(notFound); // false

// 注销后可重新定义
Model.undefine('users');
Model.define('users', newDefinition);

msq.model(collectionName)

获取 Model 实例。

缓存行为(v1.2.1+):同一 collectionName 多次调用 msq.model() 返回同一 ModelInstance 实例。索引仅在首次创建实例时触发一次 createIndexes 命令。

  • Model.redefine()Model.undefine() 后,下次调用 msq.model() 自动获取新定义的实例
  • Model._clear() 后(v1.2.2 修复),所有已注册 Model 的缓存均自动失效,下次调用 msq.model() 重建实例
  • msq.close() 后全部缓存清空
const User = msq.model('users');

// 继承所有 collection 方法
const users = await User.find({ status: 'active' });
const user = await User.findOne({ username: 'test' });

// 使用自定义静态方法
const admin = await User.findByUsername('admin');

validate(data, options)

验证数据。

const result = User.validate({
    username: 'test',
    email: 'test@example.com'
});

if (!result.valid) {
    console.error('验证失败:', result.errors);
}

数据源绑定(v1.2.2+)

通过 Model.define()connection 字段,将 Model 绑定到指定连接池和/或数据库,实现多数据源路由。

四种路由组合

pooldatabase路由目标
默认连接池 + 实例 databaseName(原逻辑,兼容 v1.2.1)
默认连接池 + 指定数据库
指定连接池 + 实例 databaseName
指定连接池 + 指定数据库

使用示例

import MonSQLize from 'monsqlize';
const { Model } = MonSQLize;

// 1. 先配置 MonSQLize 实例(声明连接池)
const msq = new MonSQLize({
    type: 'mongodb',
    databaseName: 'main_db',
    config: { uri: 'mongodb://localhost:27017' },
    pools: [
        {
            name: 'analytics',
            uri: 'mongodb://analytics-host:27017'
        }
    ]
});

// 2. 定义 Model,在 connection 中引用上面声明的池名
// 场景 1:仅切换数据库(默认连接池)
Model.define('AuditLog', {
    schema: (dsl) => dsl({ action: 'string!', userId: 'objectId' }),
    connection: { database: 'audit_db' }
});

// 场景 2:仅切换连接池(使用实例默认数据库 main_db)
Model.define('AnalyticsEvent', {
    schema: (dsl) => dsl({ event: 'string!', ts: 'date' }),
    connection: { pool: 'analytics' }
});

// 场景 3:同时切换连接池 + 数据库
Model.define('AnalyticsReport', {
    schema: (dsl) => dsl({ reportId: 'string!', data: 'object' }),
    connection: { pool: 'analytics', database: 'reports_db' }
});

// 普通 Model(无 connection,走默认逻辑)
Model.define('User', {
    schema: (dsl) => dsl({ name: 'string!', email: 'email!' })
});

// 3. 连接
await msq.connect();

// 4. 路由自动处理,调用方式不变
const AuditLogModel       = msq.model('AuditLog');        // → audit_db(默认池)
const AnalyticsEventModel = msq.model('AnalyticsEvent');  // → main_db(analytics 池)
const ReportModel         = msq.model('AnalyticsReport'); // → reports_db(analytics 池)
const UserModel           = msq.model('User');            // → main_db(默认池,原逻辑)

错误码

错误码触发条件
NO_POOL_MANAGERModel 配置了 pool,但构造函数未配置 pools
POOL_NOT_FOUNDpool 指定的名称不存在于已注册的连接池
INVALID_MODEL_DEFINITIONpooldatabase 为空字符串

Model 自动加载(v1.0.7+)

功能说明

monSQLize 支持自动扫描指定目录,加载所有 Model 定义文件,无需手动调用 Model.define()

使用方式

简化配置

const msq = new MonSQLize({
    type: 'mongodb',
    databaseName: 'mydb',
    config: { uri: '...' },
    models: './models'  // ← 自动加载
});

await msq.connect();  // 自动扫描 models/*.model.{js,ts,mjs,cjs}

// 直接使用(无需 Model.define)
const User = msq.model('users');

⚠️ 路径解析规则:相对路径以 process.cwd()(Node.js 进程启动目录)为基准,不是以 new MonSQLize() 所在文件的目录为基准。通常在项目根目录启动服务时,'./models' 等价于 <项目根>/models/。如需避免歧义,建议使用绝对路径:

models: path.join(__dirname, 'models')

完整配置

const msq = new MonSQLize({
    models: {
        path: path.join(__dirname, 'models'), // 推荐:绝对路径,基于当前文件目录
        pattern: '*.model.js',               // 文件名模式
        recursive: true                      // 递归扫描子目录
    }
});

Model 文件格式

// models/user.model.js
module.exports = {
    name: 'users',  // 集合名称(必需)
    
    schema: (dsl) => dsl({
        username: 'string:3-32!',
        email: 'email!'
    }),
    
    methods: (model) => ({
        instance: {
            checkPassword(password) {
                return this.password === password;
            }
        },
        static: {
            async findByUsername(username) {
                return await model.findOne({ username });
            }
        }
    }),
    
    hooks: (model) => ({
        insert: {
            before: async (ctx, doc) => {
                doc.createdAt = new Date();
                return doc;
            }
        }
    }),
    
    indexes: [
        { key: { username: 1 }, unique: true }
    ]
};

目录结构

models/
├── user.model.js
├── post.model.js
├── comment.model.js
└── admin/
    ├── role.model.js
    └── permission.model.js

支持的文件格式

  • .js - CommonJS
  • .mjs - ES Module
  • .cjs - CommonJS(显式)
  • .ts - TypeScript(需要 ts-node)

配置选项

选项类型默认值说明
pathstring-Model 文件目录(必需)
patternstring*.model.{js,ts,mjs,cjs}文件名模式(支持 glob)
recursivebooleanfalse是否递归扫描子目录

错误处理

目录不存在

[Model] Models directory not found: /path/to/models

文件格式错误

[Model] ❌ Failed to load models/invalid.model.js: export is null
[Model] ❌ Failed to load models/no-name.model.js: missing 'name' property

重复注册

[Model] Model 'users' already registered, skipping models/user2.model.js

最佳实践

  1. 统一命名规范: 使用 {name}.model.js 格式
  2. 按功能分组: 使用子目录组织(如 admin/public/
  3. 导出格式: 始终使用 module.exports = { name, ... }
  4. 测试环境: 可以禁用自动加载,手动注册测试 Model

注意事项

  • ⚠️ 文件必须包含 name 属性
  • ⚠️ 重复的 Model 名称只会注册第一个
  • ⚠️ 文件语法错误会导致加载失败(记录日志,不中断)
  • ⚠️ TypeScript 文件需要运行时支持(ts-node 或编译后)

与手动注册对比

方式优点缺点
手动注册精确控制、显式依赖重复代码、维护成本高
自动加载自动发现、代码简洁隐式依赖、加载顺序不确定

推荐: 生产环境使用自动加载,测试环境可选择手动注册。


Schema 验证(v1.0.7 默认启用)

功能说明

monSQLize 集成 schema-dsl 提供数据验证功能。从 v1.0.7 开始,Schema 验证默认启用

基本使用

import { Model } from 'monsqlize';

Model.define('users', {
    schema: (dsl) => dsl({
        username: 'string:3-32!',      // 必需,3-32 字符
        email: 'email!',               // 必需,邮箱格式
        password: 'string:6-!',        // 必需,至少 6 字符
        age: 'number:0-120',           // 可选,0-120 范围
        role: 'string?'                // 可选字符串
    })
});

const User = msq.model('users');

// ✅ v1.0.7: 验证自动生效(无需配置)
await User.insertOne({
    username: 'john',
    email: 'john@example.com',
    password: 'secret123',
    age: 25
});

// ❌ 验证失败
try {
    await User.insertOne({
        username: 'ab',        // 太短
        email: 'invalid',      // 邮箱格式错误
        password: '123'        // 太短
    });
} catch (err) {
    console.error(err.code);    // 'VALIDATION_ERROR'
    console.error(err.message); // 'Schema validation failed: ...'
    console.error(err.errors);  // 详细错误数组
}

Schema 语法

类型语法示例
字符串string'string!' 必需字符串
字符串范围string:min-max'string:3-32!' 3-32 字符
数字number'number!' 必需数字
数字范围number:min-max'number:0-120' 0-120 范围
邮箱email'email!' 邮箱格式
URLurl'url!' URL 格式
数组array'array!' 必需数组
对象object'object!' 必需对象
布尔boolean'boolean!' 必需布尔值
日期date'date!' Date 对象
可选type?'string?' 可选字符串
必需type!'string!' 必需字符串

更多语法见 schema-dsl 文档

验证错误详情

try {
    await User.insertOne({ username: 'ab', email: 'invalid' });
} catch (err) {
    console.log(err.code);     // 'VALIDATION_ERROR'
    console.log(err.message);  // 'Schema validation failed: ...'
    console.log(err.errors);   // 详细错误数组
    /*
    [
        {
            field: 'username',
            type: 'length',
            expected: '3-32',
            actual: 2,
            message: 'username must be 3-32 characters'
        },
        {
            field: 'email',
            type: 'format',
            expected: 'email',
            message: 'email must be a valid email address'
        }
    ]
    */
}

禁用验证

全局禁用(不推荐)

Model.define('users', {
    schema: (dsl) => dsl({ ... }),
    options: { validate: false }  // 全局禁用验证
});

单次操作跳过

// 跳过验证(特殊场景,如数据迁移)
await User.insertOne(doc, { skipValidation: true });

v1.0.6 迁移指南

版本验证行为配置方式
v1.0.6需要显式启用options: { validate: true }
v1.0.7默认启用无需配置(默认生效)
v1.0.7 禁用需要显式禁用options: { validate: false }

升级注意事项:

  • ✅ 未定义 schema 的 Model 不受影响
  • ✅ 已显式配置 validate: true 的代码无需修改
  • ⚠️ 如需禁用,需添加 validate: false 配置

性能影响

  • 验证开销: 约 5-10% 插入时间增加
  • 缓存优化: Schema 编译后缓存,重复使用无额外开销
  • 跳过选项: 可通过 skipValidation 跳过(不推荐)

最佳实践

  1. 始终定义 Schema: 保证数据质量
  2. 合理使用可选字段: 避免过度严格
  3. 自定义验证: 使用 hooks 添加复杂验证逻辑
  4. 错误处理: 捕获 VALIDATION_ERROR 返回友好错误

常见问题

Q: 如何验证嵌套对象?

A: 使用 schema-dsl 的嵌套语法:

schema: (dsl) => dsl({
    profile: dsl({
        name: 'string!',
        age: 'number!'
    })
})

Q: 如何自定义验证逻辑?

A: 使用 hooks 添加复杂验证:

hooks: (model) => ({
    insert: {
        before: async (ctx, doc) => {
            if (doc.age < 18) {
                throw new Error('Must be 18+');
            }
            return doc;
        }
    }
})

Q: 性能敏感场景如何优化?

A: 批量插入时可选择跳过验证(风险自负):

await User.insertMany(docs, { skipValidation: true });

配置说明

1. schema - 数据验证

定义字段验证规则。

// 推荐:使用 function 可引用 enums
schema: function(dsl) {
    return dsl({
        username: 'string:3-32!',
        email: 'email!',
        age: 'number:0-120',
        role: this.enums.role.default('user')  // 引用 enums
    });
}

// 或直接使用 object
schema: (dsl) => dsl({
    username: 'string:3-32!',
    email: 'email!'
})

常用规则

  • string! - 必填字符串
  • string:3-32 - 长度 3-32
  • number:0-120 - 数字范围
  • email! - 邮箱格式
  • .default('value') - 默认值
  • .pattern(/regex/) - 正则验证

2. methods - 自定义方法

instance 方法

注入到查询返回的文档对象。

methods: (model) => ({
    instance: {
        checkPassword(password) {
            return this.password === password;  // this = 文档对象
        },
        isAdmin() {
            return this.role === 'admin';
        }
    }
})

// 使用
const user = await User.findOne({ username: 'test' });
user.checkPassword('secret123');  // ✅
user.isAdmin();                   // ✅

注意

  • ⚠️ 必须使用普通函数,不能用箭头函数
  • ⚠️ 方法名避免与字段名冲突,使用动词前缀:is*, check*, get*
  • ⚠️ 修改 this 不会自动保存到数据库

static 方法

挂载到 Model 实例。

methods: (model) => ({
    static: {
        async findByUsername(username) {
            return await model.findOne({ username });
        },
        async findAdmins() {
            return await model.find({ role: 'admin' });
        }
    }
})

// 使用
const User = msq.model('users');
const user = await User.findByUsername('test');  // ✅
const admins = await User.findAdmins();          // ✅

3. hooks - 生命周期钩子

在操作前后执行自定义逻辑。

hooks: (model) => ({
    insert: {
        before: async (ctx, docs) => {
            // 自动添加时间戳
            return { ...docs, createdAt: new Date() };
        },
        after: async (ctx, result) => {
            console.log('插入完成');
        }
    },
    update: {
        before: async (ctx, filter, update) => {
            if (!update.$set) update.$set = {};
            update.$set.updatedAt = new Date();
            return [filter, update];
        }
    }
})

支持的操作find, insert, update, delete

ctx 上下文:用于在 before 和 after 之间传递数据

before: async (ctx, docs) => {
    ctx.timestamp = Date.now();
},
after: async (ctx, result) => {
    console.log('耗时:', Date.now() - ctx.timestamp);
}

4. indexes - 自动创建索引

indexes: [
    { key: { username: 1 }, unique: true },      // 唯一索引
    { key: { status: 1, createdAt: -1 } },       // 复合索引
    { key: { expireAt: 1 }, expireAfterSeconds: 0 }  // TTL 索引
]

5. enums - 枚举配置

enums: {
    role: 'admin|user|guest',
    status: 'active|inactive'
}

// schema 中引用
schema: function(dsl) {
    return dsl({
        role: this.enums.role.default('user')
    });
}

完整示例

import MonSQLize from 'monsqlize';
const { Model } = MonSQLize;

// 定义 User Model
Model.define('users', {
    enums: {
        role: 'admin|user|guest',
        status: 'active|inactive|banned'
    },
    schema: function(dsl) {
        return dsl({
            username: 'string:3-32!',
            email: 'email!',
            password: 'string!'.pattern(/^[a-zA-Z0-9]{6,30}$/),
            role: this.enums.role.default('user'),
            status: this.enums.status.default('active'),
            loginCount: 'number'.default(0),
            lastLoginAt: 'date',
            createdAt: 'date!',
            updatedAt: 'date!'
        });
    },
    methods: (model) => ({
        instance: {
            checkPassword(password) {
                return this.password === password;
            },
            isAdmin() {
                return this.role === 'admin';
            },
            async incrementLogin() {
                return await model.updateOne(
                    { _id: this._id },
                    { 
                        $inc: { loginCount: 1 },
                        $set: { lastLoginAt: new Date() }
                    }
                );
            }
        },
        static: {
            async findByUsername(username) {
                return await model.findOne({ username });
            },
            async findActive() {
                return await model.find({ status: 'active' });
            },
            async countAdmins() {
                return await model.count({ role: 'admin' });
            }
        }
    }),
    hooks: (model) => ({
        insert: {
            before: async (ctx, docs) => {
                const now = new Date();
                return {
                    ...docs,
                    createdAt: now,
                    updatedAt: now
                };
            }
        },
        update: {
            before: async (ctx, filter, update) => {
                if (!update.$set) update.$set = {};
                update.$set.updatedAt = new Date();
                return [filter, update];
            }
        }
    }),
    indexes: [
        { key: { username: 1 }, unique: true },
        { key: { email: 1 }, unique: true },
        { key: { status: 1, createdAt: -1 } }
    ]
});

// 使用
const msq = new MonSQLize({
    type: 'mongodb',
    config: { uri: 'mongodb://localhost:27017/mydb' }
});
await msq.connect();

const User = msq.model('users');

// 创建用户
const result = User.validate({
    username: 'admin',
    email: 'admin@example.com',
    password: 'secret123',
    role: 'admin'
});

if (result.valid) {
    await User.insertOne(result.data);
}

// 登录验证
const user = await User.findByUsername('admin');
if (user && user.checkPassword('secret123')) {
    if (user.isAdmin()) {
        console.log('管理员登录');
    }
    await user.incrementLogin();
}

// 查询活跃用户
const activeUsers = await User.findActive();

// 统计管理员数量
const adminCount = await User.countAdmins();

注意事项

⚠️ 方法命名避免冲突

方法名不要与字段名相同,使用动词前缀。

// ❌ 错误
methods: { instance: { status() {} } }

// ✅ 正确
methods: { 
    instance: { 
        isActive() {},      // is* 判断
        checkStatus() {},   // check* 验证
        getFullName() {}    // get* 获取
    } 
}

⚠️ 必须使用普通函数

不能使用箭头函数,否则 this 指向错误。

// ❌ 错误
checkPassword: (password) => this.password === password

// ✅ 正确
checkPassword(password) { return this.password === password; }

⚠️ 修改不会自动保存

方法内修改 this 只改内存,不会保存到数据库。

// ❌ 错误:只改内存
updatePassword(pwd) { this.password = pwd; }

// ✅ 正确:调用更新方法
async changePassword(pwd) {
    return await model.updateOne(
        { _id: this._id },
        { $set: { password: pwd } }
    );
}

自动时间戳(v1.0.3+)

自动管理 createdAtupdatedAt 字段。

基本用法

Model.define('users', {
    schema: (dsl) => dsl({ username: 'string!' }),
    options: {
        timestamps: true  // 启用自动时间戳
    }
});

// 插入时自动添加
await User.insertOne({ username: 'john' });
// => { _id, username: 'john', createdAt: Date, updatedAt: Date }

// 更新时自动更新 updatedAt
await User.updateOne({ username: 'john' }, { $set: { status: 'active' } });
// => updatedAt 自动更新为当前时间

自定义字段名

Model.define('users', {
    options: {
        timestamps: {
            createdAt: 'created_time',  // 自定义创建时间字段名
            updatedAt: 'updated_time'   // 自定义更新时间字段名
        }
    }
});

部分启用

// 只启用 createdAt
Model.define('users', {
    options: {
        timestamps: {
            createdAt: true,
            updatedAt: false
        }
    }
});

// 只启用 updatedAt
Model.define('users', {
    options: {
        timestamps: {
            createdAt: false,
            updatedAt: true
        }
    }
});

支持的操作

操作createdAtupdatedAt说明
insertOne同时添加两个字段
insertMany每个文档都添加
updateOne只更新 updatedAt
updateMany所有匹配文档更新
replaceOne替换时更新
upsertOne✅/❌插入时添加 createdAt,更新时不添加
findOneAndUpdate只更新 updatedAt
findOneAndReplace替换时更新
incrementOne⚠️ 暂不支持

注意事项

⚠️ 用户手动设置会被覆盖

await User.insertOne({
    username: 'john',
    createdAt: new Date('2020-01-01')  // 会被覆盖
});
// => createdAt 会是当前时间,不是 2020-01-01

如需保留用户设置的值,请暂时禁用 timestamps 或在 before hook 中处理。

⚠️ incrementOne 暂不支持

// incrementOne 不会自动更新 updatedAt
await User.incrementOne({ _id }, { score: 10 });

临时方案:手动添加 updatedAt

await User.updateOne(
    { _id },
    { 
        $inc: { score: 10 },
        $set: { updatedAt: new Date() }
    }
);

✅ 与 schema 验证配合

timestamps 自动添加的字段会通过 schema 验证(如果 schema 中有定义)。

Model.define('users', {
    schema: (dsl) => dsl({
        username: 'string!',
        createdAt: 'date',    // 可选:定义验证规则
        updatedAt: 'date'
    }),
    options: {
        timestamps: true       // 自动添加的值会通过验证
    }
});

✅ 与 hooks 配合

timestamps 在用户 hooks 之后执行,不会影响用户的 before hook。

Model.define('users', {
    options: { timestamps: true },
    hooks: (model) => ({
        insert: {
            before: (ctx, docs) => {
                // 用户 hook 先执行
                return { ...docs, customField: 'value' };
            }
        }
    })
});

// 执行顺序:用户 before hook → timestamps → 数据库操作 → 用户 after hook

软删除(softDelete)

版本: v1.0.3+

软删除标记文档为已删除,而非物理删除,支持数据恢复和审计。

启用软删除

// 简单模式
Model.define('users', {
    schema: (dsl) => dsl({ username: 'string!' }),
    options: {
        softDelete: true  // 使用默认配置
    }
});

// 完整配置
Model.define('posts', {
    schema: (dsl) => dsl({ title: 'string!' }),
    options: {
        softDelete: {
            enabled: true,           // 启用软删除
            field: 'deletedAt',      // 字段名(可自定义)
            type: 'timestamp',       // 'timestamp' | 'boolean'
            ttl: 86400 * 30          // TTL 索引(30天后自动清理)
        }
    }
});

配置项

选项类型默认值说明
enabledbooleantrue启用软删除
fieldstring'deletedAt'删除标记字段名
typestring'timestamp'删除标记类型('timestamp' 或 'boolean')
ttlnumbernullTTL 索引(秒),自动清理已删除数据

软删除操作

启用软删除后,deleteOnedeleteMany 会自动转换为更新操作:

const User = msq.model('users');

// 软删除(标记为已删除)
await User.deleteOne({ _id });
// 实际执行:updateOne({ _id }, { $set: { deletedAt: new Date() } })

// 批量软删除
await User.deleteMany({ status: 'inactive' });
// 实际执行:updateMany({ status: 'inactive' }, { $set: { deletedAt: new Date() } })

查询自动过滤

启用软删除后,查询操作自动过滤已删除的文档:

// 默认查询不返回已删除数据
const users = await User.find({});
// 实际执行:find({ deletedAt: null })

const user = await User.findOne({ username: 'john' });
// 实际执行:findOne({ username: 'john', deletedAt: null })

const count = await User.count({ status: 'active' });
// 实际执行:count({ status: 'active', deletedAt: null })

查询已删除数据

使用专门的方法查询包含或只查询已删除的数据:

// 查询包含已删除的所有数据
const allUsers = await User.findWithDeleted({});
const john = await User.findOneWithDeleted({ username: 'john' });
const totalCount = await User.countWithDeleted({});

// 只查询已删除的数据
const deletedUsers = await User.findOnlyDeleted({});
const deletedJohn = await User.findOneOnlyDeleted({ username: 'john' });
const deletedCount = await User.countOnlyDeleted({});

新增方法

方法说明
findWithDeleted(filter, options)查询包含已删除的文档
findOneWithDeleted(filter, options)查询单个文档(包含已删除)
countWithDeleted(filter, options)统计文档数(包含已删除)
findOnlyDeleted(filter, options)只查询已删除的文档
findOneOnlyDeleted(filter, options)查询单个已删除文档
countOnlyDeleted(filter, options)统计已删除的文档数
restore(filter, options)恢复已删除的文档
restoreMany(filter, options)批量恢复已删除的文档
forceDelete(filter, options)强制物理删除(不可恢复)
forceDeleteMany(filter, options)批量强制物理删除

恢复已删除数据

// 恢复单个文档
const result = await User.restore({ _id });
// 实际执行:updateOne({ _id, deletedAt: { $ne: null } }, { $unset: { deletedAt: 1 } })

// 批量恢复
const result = await User.restoreMany({ status: 'active' });
// 实际执行:updateMany({ status: 'active', deletedAt: { $ne: null } }, { $unset: { deletedAt: 1 } })

强制物理删除

绕过软删除机制,执行真正的物理删除(不可恢复):

// 强制物理删除单个文档
await User.forceDelete({ _id });
// 实际执行:真正的 deleteOne(数据永久删除)

// 批量强制删除
await User.forceDeleteMany({ deletedAt: { $lt: thirtyDaysAgo } });
// 实际执行:真正的 deleteMany(批量永久删除)

删除类型

timestamp 类型(默认)

Model.define('users', {
    options: {
        softDelete: { type: 'timestamp' }  // 默认
    }
});

// 删除时记录删除时间
{ _id, username: 'john', deletedAt: new Date('2026-01-05T10:30:00Z') }

// 优点:记录删除时间,支持审计
// 缺点:占用存储空间

boolean 类型

Model.define('posts', {
    options: {
        softDelete: { type: 'boolean' }
    }
});

// 删除时标记为 true
{ _id, title: 'Hello', deletedAt: true }

// 优点:节省存储空间
// 缺点:不记录删除时间

自定义字段名

Model.define('comments', {
    options: {
        softDelete: {
            enabled: true,
            field: 'removed_at'  // 自定义字段名
        }
    }
});

// 删除时使用 removed_at 字段
await Comment.deleteOne({ _id });
// { _id, content: 'Nice!', removed_at: new Date() }

TTL 索引自动清理

配置 TTL 索引,MongoDB 会自动删除过期的已删除数据:

Model.define('logs', {
    options: {
        softDelete: {
            enabled: true,
            ttl: 86400 * 30  // 30天后自动清理
        }
    }
});

// 自动创建索引:
// db.logs.createIndex({ deletedAt: 1 }, { expireAfterSeconds: 2592000 })

// MongoDB 会自动删除 deletedAt 超过 30 天的文档

与 timestamps 协同

软删除和时间戳可以同时启用:

Model.define('products', {
    schema: (dsl) => dsl({ name: 'string!' }),
    options: {
        timestamps: true,   // 自动管理 createdAt/updatedAt
        softDelete: true    // 软删除
    }
});

// 插入时自动添加时间戳
await Product.insertOne({ name: 'iPhone' });
// { _id, name: 'iPhone', createdAt: Date, updatedAt: Date }

// 软删除时自动更新 updatedAt
await Product.deleteOne({ _id });
// { _id, name: 'iPhone', createdAt: Date, updatedAt: Date(更新), deletedAt: Date }

唯一索引处理

⚠️ 注意:软删除后,唯一索引可能失效。

// 问题:用户 john 被软删除后
{ username: 'john', deletedAt: new Date() }

// 创建新用户 john 会失败(唯一索引冲突)
await User.insertOne({ username: 'john' });  // ❌ 冲突

解决方案:使用复合唯一索引

Model.define('users', {
    schema: (dsl) => dsl({ username: 'string!' }),
    options: {
        softDelete: true
    },
    indexes: [
        {
            key: { username: 1, deletedAt: 1 },  // 复合索引
            unique: true
        }
    ]
});

// 现在可以创建同名用户(因为 deletedAt 不同)

乐观锁版本控制(Version)

什么是乐观锁?

乐观锁是一种并发控制机制,通过版本号检测数据冲突:

  • 每次更新时自动递增版本号
  • 更新时验证版本号是否匹配
  • 版本号不匹配说明数据已被其他请求修改(并发冲突)

使用场景

  • 多用户同时编辑同一数据
  • 防止脏写(Lost Update)
  • 需要并发安全保证的场景

基础配置

Model.define('users', {
    schema: (dsl) => dsl({
        username: 'string!',
        email: 'string!',
        status: 'string'
    }),
    options: {
        version: true  // 启用版本控制(默认字段名 version)
    }
});

完整配置

Model.define('users', {
    schema: (dsl) => dsl({
        username: 'string!',
        email: 'string!'
    }),
    options: {
        version: {
            enabled: true,      // 是否启用
            field: '__v'        // 自定义字段名(默认 'version')
        }
    }
});

插入时自动初始化

// 插入文档
const result = await User.insertOne({
    username: 'john',
    email: 'john@example.com'
});

// 查询文档
const user = await User.findOne({ _id: result.insertedId });
console.log(user);
// { _id: '...', username: 'john', email: 'john@example.com', version: 0 }

更新时自动递增

// 第一次更新
await User.updateOne({ _id }, { $set: { status: 'active' } });
// 实际执行:{ $set: { status: 'active' }, $inc: { version: 1 } }

const user = await User.findOne({ _id });
console.log(user.version);  // 1

// 第二次更新
await User.updateOne({ _id }, { $set: { status: 'inactive' } });
console.log(user.version);  // 2

并发冲突检测

// 用户 A 读取数据
const userA = await User.findOne({ _id });
console.log(userA.version);  // 0

// 用户 B 读取数据
const userB = await User.findOne({ _id });
console.log(userB.version);  // 0

// 用户 A 先更新成功
const resultA = await User.updateOne(
    { _id, version: userA.version },
    { $set: { status: 'active' } }
);
console.log(resultA.modifiedCount);  // 1

// 用户 B 更新失败
const resultB = await User.updateOne(
    { _id, version: userB.version },  // 版本号已过期
    { $set: { status: 'inactive' } }
);
console.log(resultB.modifiedCount);  // 0(版本号不匹配)

与其他功能协同

Model.define('users', {
    options: {
        timestamps: true,  // 自动时间戳
        softDelete: true,  // 软删除
        version: true      // 版本控制
    }
});

// 所有功能协同工作
await User.insertOne({ username: 'john' });
// { _id, username, version: 0, createdAt, updatedAt }

await User.deleteOne({ _id, version: 0 });
// 软删除时版本号递增

最佳实践

// 并发更新场景
async function updateUserStatus(userId, newStatus) {
    let maxRetries = 3;
    
    for (let i = 0; i < maxRetries; i++) {
        const user = await User.findOne({ _id: userId });
        if (!user) throw new Error('User not found');
        
        const result = await User.updateOne(
            { _id: userId, version: user.version },
            { $set: { status: newStatus } }
        );
        
        if (result.modifiedCount > 0) return { success: true };
        
        console.log(`Retry ${i + 1}/${maxRetries} (version conflict)`);
    }
    
    throw new Error('Update failed due to concurrent modification');
}

完整示例

import MonSQLize from 'monsqlize';
const { Model } = MonSQLize;

// 定义 Model(启用软删除和时间戳)
Model.define('articles', {
    schema: (dsl) => dsl({
        title: 'string!',
        content: 'string!',
        author: 'string!'
    }),
    options: {
        timestamps: true,
        softDelete: {
            enabled: true,
            type: 'timestamp',
            ttl: 86400 * 30  // 30天后自动清理
        }
    },
    indexes: [
        { key: { author: 1 } },
        { key: { title: 1, deletedAt: 1 }, unique: true }  // 复合唯一索引
    ]
});

async function example() {
    const msq = new MonSQLize({ type: 'mongodb', databaseName: 'blog' });
    await msq.connect();
    
    const Article = msq.model('articles');
    
    // 1. 插入文章
    const article = await Article.insertOne({
        title: 'Hello World',
        content: 'This is my first post',
        author: 'john'
    });
    console.log('Created:', article);
    // { _id, title, content, author, createdAt, updatedAt }
    
    // 2. 软删除文章
    await Article.deleteOne({ _id: article._id });
    console.log('Article soft deleted');
    
    // 3. 查询(自动过滤已删除)
    const articles = await Article.find({ author: 'john' });
    console.log('Active articles:', articles.length);  // 0
    
    // 4. 查询包含已删除
    const allArticles = await Article.findWithDeleted({ author: 'john' });
    console.log('All articles:', allArticles.length);  // 1
    
    // 5. 恢复文章
    await Article.restore({ _id: article._id });
    console.log('Article restored');
    
    // 6. 查询(恢复后可以查到)
    const restoredArticle = await Article.findOne({ _id: article._id });
    console.log('Restored:', restoredArticle.title);  // 'Hello World'
    
    // 7. 强制物理删除
    await Article.forceDelete({ _id: article._id });
    console.log('Article permanently deleted');
    
    await msq.close();
}

example().catch(console.error);

常见问题

Q: this.password 从哪来?
A: 来自数据库查询结果,不是 schema。schema 只定义验证规则。

Q: 如何引用 enums?
A: 使用 function 定义 schema:schema: function(dsl) { return dsl({ role: this.enums.role }) }

Q: instance 和 static 的区别?
A: instance 注入到文档对象,static 挂载到 Model 实例。


更多示例

查看 examples/model/ 目录:

  • basic.js - 基础使用
  • advanced.js - 高级功能