/** * SQL 占位符与参数个数自检。 * * node-pg 会把参数数组原样交给 PostgreSQL:参数比占位符多时,服务端直接报 * `bind message supplies 5 parameters, but prepared statement "" requires 4`。 * 这类错是"只在真实库上才暴露的 500"——假 client 的单测跑不出来, * 类型检查也看不出来(撤销寄售履约回库分支就是这样炸的)。 * 所以在 db 层做一次常数级自检,把原始 pg 报错换成能直接定位的提示。 */ /** * 统计 SQL 里最大的 `$n` 占位符序号(没有占位符返回 0)。 * * 字符串字面量、注释、dollar-quote 里的 `$n` 不参与绑定,必须跳过: * 否则 `WHERE note = '$1'` 这类写法会被误判成占位符。 */ export function maxSqlPlaceholderIndex(text: string): number { let max = 0 let i = 0 const length = text.length while (i < length) { const char = text[i] const next = text[i + 1] // 行注释 if (char === '-' && next === '-') { const end = text.indexOf('\n', i + 2) i = end === -1 ? length : end + 1 continue } // 块注释(PG 允许嵌套) if (char === '/' && next === '*') { let depth = 1 i += 2 while (i < length && depth > 0) { if (text[i] === '/' && text[i + 1] === '*') { depth += 1 i += 2 } else if (text[i] === '*' && text[i + 1] === '/') { depth -= 1 i += 2 } else { i += 1 } } continue } // 单引号字符串:E'' 里反斜杠也能转义,普通字符串里只有 '' 才是转义 if (char === "'") { const prefix = text[i - 1] const escapeString = (prefix === 'E' || prefix === 'e') && !/[A-Za-z0-9_]/.test(text[i - 2] || ' ') i += 1 while (i < length) { if (escapeString && text[i] === '\\') { i += 2 continue } if (text[i] === "'") { if (text[i + 1] === "'") { i += 2 continue } i += 1 break } i += 1 } continue } // 双引号标识符:"列名" 里的 $n 不是占位符 if (char === '"') { i += 1 while (i < length) { if (text[i] === '"') { if (text[i + 1] === '"') { i += 2 continue } i += 1 break } i += 1 } continue } // dollar-quote:$$...$$ 或 $tag$...$tag$ if (char === '$') { const tagMatch = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(text.slice(i)) if (tagMatch) { const closer = tagMatch[0] const end = text.indexOf(closer, i + closer.length) i = end === -1 ? length : end + closer.length continue } const digits = /^\$(\d+)/.exec(text.slice(i)) if (digits) { max = Math.max(max, Number(digits[1])) i += digits[0].length continue } } i += 1 } return max } /** * 参数比占位符多时提前报错(PostgreSQL 同样会报,只是信息难定位)。 * * 反方向(占位符比参数多)不在这里拦:SQL 文本里出现字面量 `$5` 时容易误判, * 交给 PostgreSQL 自己报 "there is no parameter $5"。 */ export function assertSqlParamArity(text: unknown, params: unknown): void { if (typeof text !== 'string' || !Array.isArray(params) || params.length === 0) return const max = maxSqlPlaceholderIndex(text) if (max >= params.length) return throw new Error( `SQL 参数个数与占位符不匹配:提供了 ${params.length} 个参数,语句最多只用到 $${max}。` + `SQL: ${text.replace(/\s+/g, ' ').trim().slice(0, 200)}`, ) }