返回博客

配置驱动的字段别名不是真实列:动态 SQL 生成前必须先做物理映射

在元数据驱动的低代码平台里,有一类经典灾难:在配置文件里维护了十几个「逻辑字段别名」,然后在行级权限过滤、全文搜索、去规范化索引重建等场景中,把这些别名直接拼进 SQL WHERE 子句。TypeScript 编译通过,单元测试绿了,代码合并,上线,ER_BAD_FIELD_ERROR: Unknown column 'o.team_leader' in 'where clause'

现象

一个内部后台管理系统,实体对象关联了十几个「负责人角色字段」:group_leaderteam_leadergeneral_affairswriterdeclarant 等,这些字段名统一维护在一个共享常量模块里。

行级权限解析器接收这个别名列表,批量生成 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 表上。

坑:TS 类型检查和 Vitest Mock 都看不出来

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,或者在规则文档里明确标注「此别名无行级权限覆盖」。

正解:用负向断言测试锁死映射 SSOT

仅有正向断言「这个列出现在 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 实例跑一遍:

  1. 对每个 资源 × 数据权限 × 用户 组合执行生成的 SQL,必须返回无 ER_BAD_FIELD_ERROR
  2. 对所有映射为非 null 的物理列,跑一次 SHOW COLUMNS FROM <table> 确认列真实存在
  3. 对映射为 null 的别名,可选跑 SHOW COLUMNS FROM <ext_table> 确认它确实活在扩展表里(排除拼写错误)

一句话外卖

元数据平台里「字段别名」和「数据库列名」是两个层次的概念——在它们之间维护一张显式映射表,比任何运行时 try/catch 都可靠。