配置驱动的字段别名不是真实列:动态 SQL 生成前必须先做物理映射
在元数据驱动的低代码平台里,有一类经典灾难:在配置文件里维护了十几个「逻辑字段别名」,然后在行级权限过滤、全文搜索、去规范化索引重建等场景中,把这些别名直接拼进 SQL WHERE 子句。TypeScript 编译通过,单元测试绿了,代码合并,上线,ER_BAD_FIELD_ERROR: Unknown column 'o.team_leader' in 'where clause'。
现象
一个内部后台管理系统,实体对象关联了十几个「负责人角色字段」:group_leader、team_leader、general_affairs、writer、declarant 等,这些字段名统一维护在一个共享常量模块里。
行级权限解析器接收这个别名列表,批量生成 OR 条件:
const ASSIGNEE_FIELDS = ['group_leader', 'team_leader', 'general_affairs', 'writer', ...]
const where = ASSIGNEE_FIELDS.map(f => `${tableAlias}.${f} IN (?, ?)`).join(' OR ')本地开发一切正常,CI 单元测试全绿,但切换到真实数据库的冒烟测试时,立刻报错:
ER_BAD_FIELD_ERROR: Unknown column 'o.team_leader' in 'where clause'
根因
元数据驱动平台里,逻辑字段与物理列不是 1:1 关系。这十几个别名的实际归属如下:
| 别名 | 实际位置 |
|---|---|
| group_leader | 目标表上的真实列 ✓ |
| team_leader | group_leader 的逻辑别名(同一物理列,不同展示名) ✗ |
| general_affairs | 来自扩展表的投影字段(因报表编码而异) ✗ |
| writer / declarant / ... | 同上,均为扩展表投影 ✗ |
12 个别名中只有 1 个对应主表物理列,其余 11 个根本不存在于 orders 表上。
TypeScript 把别名列表的类型定成 readonly string[],编译器不知道哪些字符串是数据库里的真实列名。Vitest 用 mockResolvedValueOnce([rows]) 拦截 pool.query(),接受任意 SQL 字符串,根本不管 SQL 能否在真实 schema 上执行。只有对接真实 MySQL 实例的冒烟测试才能暴露这个问题。
正解
维护一张显式的「逻辑别名 → 物理列」映射表,值为 null 表示该别名在目标表上无对应物理列,生成 SQL 时跳过:
// engine/permission-resolver/build-scope-query.ts
const LOGICAL_ALIAS_TO_PHYSICAL_COLUMN: Record<string, string | null> = {
// 真实列 → 直接生成谓词
group_leader: 'group_leader',
// 逻辑别名 → 映射到规范物理列名
team_leader: 'group_leader',
// 扩展表投影 / 不存在于主表 → null,走其他回退路径
general_affairs: null,
writer: null,
declarant: null,
document_clerk: null,
// ...其余全 null
}
function resolvePhysicalColumns(aliases: readonly string[]): string[] {
const mapped = aliases
.map((a) => LOGICAL_ALIAS_TO_PHYSICAL_COLUMN[a] ?? null)
.filter((c): c is string => typeof c === 'string' && c.length > 0)
return Array.from(new Set(mapped)) // 去重:多个别名可能对应同一物理列
}映射为 null 的别名,其语义覆盖要走独立的回退路径,例如关联表 EXISTS 子查询、扩展表动态 JOIN,或者在规则文档里明确标注「此别名无行级权限覆盖」。
仅有正向断言「这个列出现在 SQL 里」还不够。必须加负向断言,防止未来的人「好心」把 null 改成非 null 导致 SQL 崩溃:
it('只对主表真实列生成 SQL 谓词', () => {
const result = buildScopeQuery({
tableAlias: 'o',
assigneeFieldAliases: ['group_leader', 'team_leader', 'general_affairs'],
})
expect(result.where[0]).toContain('o.group_leader IN (?, ?)')
// team_leader 已折叠进 group_leader,不应再单独出现
expect(result.where[0]).not.toContain('o.team_leader')
// general_affairs 是扩展表字段,不应出现在主表谓词里
expect(result.where[0]).not.toContain('o.general_affairs')
})not.toContain 这两行是整个防线的核心——没有它们,映射表被改坏时测试仍然绿灯。
实测验证
上线前必须对接真实 MySQL 实例跑一遍:
- 对每个
资源 × 数据权限 × 用户组合执行生成的 SQL,必须返回无ER_BAD_FIELD_ERROR - 对所有映射为非 null 的物理列,跑一次
SHOW COLUMNS FROM <table>确认列真实存在 - 对映射为 null 的别名,可选跑
SHOW COLUMNS FROM <ext_table>确认它确实活在扩展表里(排除拼写错误)
一句话外卖
元数据平台里「字段别名」和「数据库列名」是两个层次的概念——在它们之间维护一张显式映射表,比任何运行时 try/catch 都可靠。