前面几篇一直在讲 Java / Spring 项目里的动态 SQL。
从最早的字符串拼接,到 FSScript 风格的 SQL 模板,再到固定 helper、权限条件注入,这一小段其实可以先收住了。它解决的是一个很具体的问题:在已经使用 Spring JPA / Hibernate 的企业项目里,复杂查询不要继续散落在 Repository、Service、JdbcTemplate 的拼接分支里。
但企业系统里不一定只有关系型数据库。
很多项目会在后面接入 MongoDB。原因也很常见:有些数据天生更像文档,比如操作日志、事件流、配置快照、表单扩展字段、外部系统回调、非固定结构的业务记录。它们放进 MySQL / PostgreSQL 不是不行,但字段扩展、嵌套结构和历史版本会越来越别扭。
问题是,一旦加了 Mongo,我们并不希望查询代码又回到另一套混乱状态。
如果 SQL 这边已经开始用模板来管理动态条件、分页、排序、当前用户和权限上下文,Mongo 这边最好也能保持相似的工程形态。
否则后端代码很快会变成两种风格:
// 关系型查询:一套模板和 helper
orderDataset.query(form);
另一边是:
// Mongo 查询:在 Service 里手写一堆 Criteria
Query query = new Query();
if (status != null) {
query.addCriteria(Criteria.where("status").is(status));
}
if (keyword != null) {
query.addCriteria(Criteria.where("orderNo").regex(keyword));
}
query.addCriteria(Criteria.where("tenantId").is(currentUser.getTenantId()));
query.with(Sort.by(Sort.Direction.DESC, "createTime"));
这不是 MongoTemplate 不好用。它在 Java 代码里很直接,也足够强。
但如果一个项目里已经有很多可选条件、分页查询、导出查询和权限条件,Criteria 同样会遇到动态 SQL 那类问题:条件分支散落、参数来源混杂、权限边界不稳定、AI 辅助修改时不容易看出哪些字段不能动。
所以早期做 Mongo 接入时,一个自然的想法是:不要重新发明一套完全不同的东西,而是让 Mongo 查询也走 FSScript 风格。
大致形态可以是这样:
import '@mcpMongoTemplate';
import {getCurrentUser, getDataScope} from '@authService';
export const setName = 'order_event';
export const mongoTemplate = mcpMongoTemplate;
const user = getCurrentUser();
const scope = getDataScope('order_event');
export function buildMongo() {
const query = {
tenantId: user.tenantId,
teamId: {$in: scope.teamIds}
};
if (form.param.status) {
query.status = form.param.status;
}
if (form.param.keyword) {
query.orderNo = {$regex: form.param.keyword};
}
return query;
}
export const sort = {
createTime: -1
};
这个示例重点不在 Mongo 语法,而在几个边界。
第一,集合名是脚本显式导出的。
export const setName = 'order_event';
它不是让调用方临时传一个 collection 名字,也不是让 AI 自己猜该查哪个集合。查询脚本本身知道自己面向哪个数据集。
第二,MongoTemplate 可以来自 Spring。
import '@mcpMongoTemplate';
export const mongoTemplate = mcpMongoTemplate;
这和前面 SQL 模板里通过 import '@springBean' 接入 Spring 服务是一致的。连接、事务边界、环境配置、Bean 生命周期仍然交给 Spring,不需要在脚本里重新管理。
第三,权限上下文还是从系统里来。
const user = getCurrentUser();
const scope = getDataScope('order_event');
Mongo 查询并不因为换了数据库,就可以绕过当前用户、租户、团队范围这些条件。关系型数据库里需要注入的数据范围,Mongo 里同样需要。
第四,脚本导出的是 Mongo 查询对象或构建函数。
export function buildMongo() {
const query = {
tenantId: user.tenantId,
teamId: {$in: scope.teamIds}
};
if (form.param.status) {
query.status = form.param.status;
}
return query;
}
底层执行时,引擎可以拿到 mongo 或 buildMongo() 的结果,再交给 MongoCollection 执行 find;如果脚本返回的是 aggregation,也可以走 aggregate。分页、limit、skip、sort 仍然由统一的查询执行过程处理。
这一步的意义不是把 Mongo 查询包装得更炫。
它只是把 SQL 模板阶段已经得到的一些经验,迁移到文档数据库上:
- 查询条件继续放在脚本里,而不是散在 Java 分支里;
- Spring 上下文继续可以注入;
- 当前用户和数据范围继续由系统提供;
- 分页和排序继续走统一执行路径;
- 返回结果继续可以进入统一的 PagingResult;
- AI 生成或修改查询时,仍然面对一段人工可读的模板。
对 LLM 来说,这个成本也很低。
如果让它直接写一大段 Java Criteria 代码,它需要理解 Spring Data MongoDB API、对象链式调用、分页排序、参数来源,还要保证不会漏掉权限条件。
但如果告诉它:这是一个 FSScript Mongo 模板,你只需要在 buildMongo() 里补页面筛选条件,tenantId 和 teamId 这些系统条件必须保留,它的任务就变小了。
这和 SQL 模板阶段的思路一样:不是让 AI 接管数据库查询,而是让它在一个更短、更稳定、更容易审查的模板里工作。
当然,这也不是完整的 Mongo 语义层。
它没有解决所有文档结构治理问题,也不意味着可以把任意 Mongo 查询开放给外部调用。它只是一个扩展点:当项目里已经开始用模板管理 SQL 查询时,Mongo 查询也可以用类似方式接进来,避免因为换了数据源就丢掉已有的工程边界。
在 Foggy 早期实现里,这部分对应的是 foggy-dataset-mongo:通过 .ms 脚本加载 Mongo 查询,导出 setName、mongoTemplate、mongo 或 buildMongo,执行时支持 find / aggregate、分页、排序和 Spring Bean 注入。
再往后,Mongo 也可以进入模型化阶段,例如 type: 'mongo' 的 TM,以及基于它暴露字段的 QM。但那是下一层问题。
这一篇先停在更早的位置:SQL 模板不是终点,它证明的是一种写法。只要边界足够清楚,同样的写法也可以扩展到 Mongo。
相关代码:foggy-dataset-mongo
相关模型示例:Mongo TM / QM 测试模型