/** * metona-sqlark 列引用取值 —— WHERE / 投影 / 聚合 / 表达式**共用**的唯一实现 * @module query/column-value * * ============================================================================ * 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1) * ============================================================================ * "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同: * * | 位置 | 别名前缀 | 后缀回退 | 未知列 | * |---|---|---|---| * | `projectColumns`(where-matcher) | 否 | 唯一后缀 | 静默丢键 | * | `resolveAliasSource`(executor) | **否** | 否 | 静默 undefined | * | 聚合参数(executor) | 否 | 否 | **静默计 0**(A25) | * | `matchWhere` 的 `resolveField` | 否 | 唯一后缀(多个则 UNRESOLVED) | UNRESOLVED | * * 于是 `COUNT(t.n)` 返回 0(A25)、`SELECT d.id FROM (...) AS d` 返回空集(A36)、 * CASE 的 THEN 分支引用列名时行为随调用点变化(B-4)。 * * 现在四处都调用本模块的 `resolveColumnValue`: * - JOIN 行的键是 `alias.col`,单表行的键是 `col` —— 两种形态都要支持; * - 取不到值时**由调用方**决定是抛错还是返回 UNRESOLVED(`strict` 选项), * 因为"WHERE 里的未解析引用"与"表达式里的未解析引用"需要不同的上层处理。 */ import { DatabaseError } from '../constants'; import { UNRESOLVED } from './sql-compare'; /** 取值选项 */ export interface ResolveOptions { /** * 取不到值时是否抛 `COLUMN_NOT_FOUND`。 * - `true`:用于 GROUP BY / 聚合参数 / 投影等"这里必须有一列"的场景; * - `false`:返回 `UNRESOLVED` 哨兵,由调用方决定(WHERE 需要据此判 UNKNOWN, * 表达式需要据此抛错,而"行里确实没有该键"与"该列值为 NULL"必须区分)。 */ strict: boolean; /** 错误消息中的位置描述(如 'GROUP BY' / 'aggregate COUNT(n)' / 'SELECT list') */ context: string; } /** * 从行里取一个列引用。 * * 解析顺序(与 ORDER BY 的 `stripAlias` 语义一致,保证同一引用在各子句里等价): * 1. 精确命中(行键与引用完全一致,含 `alias.col` 形态); * 2. 剥离别名前缀(`t.n` → `n`); * 3. 唯一后缀匹配(行键是 `t.n` 而引用写作 `n`); * 4. 以上都不中 → 依 `strict` 抛错或返回 `UNRESOLVED`。 * * 多个后缀命中视为**歧义**(JOIN 里两表同名列),`strict` 下抛错 —— * 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。 */ export function resolveColumnValue( row: Record, reference: string, opts: ResolveOptions, ): unknown { const text = reference.trim(); if (text in row) return row[text]; // 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀 if (text.includes('.')) { const bare = text.split('.').pop()!; if (bare in row) return row[bare]; } // 唯一后缀匹配:行键 `t.n` 而引用写作 `n` let found: unknown; let hits = 0; for (const key of Object.keys(row)) { if (key.endsWith(`.${text}`)) { found = row[key]; hits += 1; } } if (hits === 1) return found; if (opts.strict) { throw new DatabaseError( hits > 1 ? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables` : `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text }, ); } return UNRESOLVED; }