pg-sql2 的 sql.identifier() 完全指南:安全构建动态 SQL 标识符
2026/9/24 14:30:52 网站建设 项目流程
  • 后端
  • API网关

【免费下载链接】crystal

🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!

项目地址:https://gitcode.com/gh_mirrors/cry/crystal
点击查看免费下载

sql.identifier()是 Graphile 生态中 PostgreSQL 查询构建库 pg-sql2 提供的核心 API,用于把表名、列名、schema 名等数据库对象名安全地转义为合法的 SQL 标识符,从根本上杜绝动态拼接标识符引发的 SQL 注入。本文以 utils/website/pg-sql2/api/sql-identifier.md 文档为主体,结合 utils/pg-sql2/src/index.ts 源码实现与 utils/pg-sql2/tests/general.test.ts 测试用例,系统讲解其语法、全部用法场景、Symbol 别名机制以及底层实现原理。读完本文,你将能够在自己的动态 SQL 场景中安全、正确地使用标识符,并理解 pg-sql2 如何将标识符问题与值绑定问题彻底分离。

为什么需要专门处理 SQL 标识符

在构建动态 SQL 时,开发者经常需要把运行期才确定的表名、列名拼进查询字符串,例如:

const tableName = getUserInput(); // 用户输入,不可信! const query = `SELECT * FROM ${tableName}`;

一旦tableNameusers; DROP TABLE users; --之类的恶意输入,整个查询就会被注入攻击者控制的 SQL。即使使用参数化查询,占位符($1)也只能保护(字符串、数字等字面量),无法用于表名、列名这类标识符——PostgreSQL 不允许把占位符当作标识符使用。

sql.identifier()正是为此而生:它接收一个或多个名称,将每个名称分别安全转义后以点号连接(如"schema"."table"."column"),返回一个可嵌入其他 SQL 片段的SQL片段对象。这样动态表名/列名就可以安全地参与查询构建,同时完全避免注入风险。

语法与参数

sql.identifier在 utils/pg-sql2/src/index.ts 中定义,类型签名如下:

sql.identifier(name: string | symbol): SQL sql.identifier(name1: string | symbol, name2: string | symbol, ...): SQL

参数:

  • name— 字符串或 Symbol,表示一个标识符名称(表名、列名、schema 名、函数名等)。
  • 可传多个名称,用于构建点号分隔的限定标识符(qualified identifier)。

返回值:

返回一个SQL片段,表示转义后的标识符,可直接嵌入sql模板字面量或其他 SQL 表达式中,最终通过sql.compile()得到可执行的textvalues

参数约束(源码实现细节):

  • 至少传入一个参数,否则抛出错误"[pg-sql2] Invalid call to sql.identifier() - you must pass at least one argument."(src/index.ts)。
  • 每个参数必须是字符串或 Symbol,传入其他类型(如数字、对象)会抛出错误,例如[pg-sql2] Invalid argument to sql.identifier - argument 1 (0-indexed) should be a string or a symbol...(src/index.ts)。这意味着你无法直接用数字构造标识符,需先转成字符串。

基本用法:单个标识符

表名

import { sql } from "pg-sql2"; // 表名 const tableName = "users"; const query = sql`SELECT * FROM ${sql.identifier(tableName)}`; console.log(sql.compile(query).text); // SELECT * FROM "users"

列名

// 列名 const columnName = "user_name"; const query = sql`SELECT ${sql.identifier(columnName)} FROM users`; console.log(sql.compile(query).text); // SELECT "user_name" FROM users

注意编译结果中的标识符总是被双引号包裹。测试 general.test.ts 也验证了这一点:sql.identifier("foo")对应节点为{ t: '"foo"' }

限定标识符:多参数点号连接

当传入多个参数时,每个参数会被单独转义,再用点号连接,从而生成"schema"."table""table"."column""schema"."table"."column"等限定形式:

import { sql } from "pg-sql2"; // schema.table const schema = "public"; const table = "users"; const column = "name"; const query = sql`SELECT ${sql.identifier(column)} FROM ${sql.identifier(schema, table)}`; console.log(sql.compile(query).text); // SELECT * FROM "public"."users"
// table.column const query = sql`SELECT ${sql.identifier(table, column)} FROM users`; console.log(sql.compile(query).text); // SELECT "users"."name" FROM users
// schema.table.column const query = sql`COMMENT ON COLUMN ${sql.identifier(schema, table, column)} IS ''`; console.log(sql.compile(query).text); // COMMENT ON COLUMN "public"."users"."name" IS '';

测试用例同样覆盖了多参数场景:sql.identifier("foo", "bar", 'b"z')被编译为"foo"."bar"."b""z"(general.test.ts),其中内部的"被正确转义为""

使用 Symbol 生成唯一别名

sql.identifier()还接受 Symbol 参数,用于生成唯一且安全的别名。这在同一张表需要被引用多次(自连接、重复子查询)时尤其有用——你不需要手工维护"不会撞名"的别名,库会替你保证唯一性:

import { sql } from "pg-sql2"; const worker = sql.identifier(Symbol("worker")); const boss = sql.identifier(Symbol("boss")); const query = sql` SELECT ${worker}.name, ${boss}.salary/${worker}.salary as boss_multiplier FROM employees AS ${worker} INNER JOIN employees AS ${boss} ON ${worker}.manager_id = ${boss}.id `; console.log(sql.compile(query).text); /* SELECT __worker__.name, __boss__.salary/__worker__.salary as boss_multiplier FROM employees AS __worker__ INNER JOIN employees AS __boss__ ON __worker__.manager_id = __boss__.id */

给 Symbol 起有意义的名字

上述示例即使使用无描述的空 Symbol 也能工作,只是编译出的别名会退化为__local_0____local_1__这类形式,可读性较差:

const worker = sql.identifier(Symbol()); const boss = sql.identifier(Symbol()); // FROM employees AS __local_0__ // INNER JOIN employees AS __local_1__

因此强烈建议在 Symbol 的 description 中填写语义化名称(如Symbol("worker")),既方便调试,也让生成的 SQL 更易读。

Symbol 别名的底层规则

从源码看,Symbol 别名机制包含以下关键点(src/index.ts、src/index.ts):

  • 名称整理(mangle):Symbol 的 description 会经mangleName()处理,只保留[0-9a-z_]字符,长度限制在 50 个字符以内,去掉首尾与连续下划线;空描述回退为"local"。这样生成的别名无需转义即可安全使用,与字符串标识符"总是加引号"的策略形成互补。
  • 编译期分配:字符串标识符在sql.identifier()调用时就完成转义,而 Symbol 标识符要等到sql.compile()阶段才真正生成名字。编译时用symbolToIdentifier这个 Map 记录每个 Symbol 对应的别名,并配合descCounter计数器保证:同一个 Symbol 在单次编译中始终映射到同一个别名(即使该片段出现多次),而不同 Symbol 即使 description 相同也会得到不同别名。
  • 命名格式:第一个实例命名为__name__,后续同名实例依次为__name_2__name_3……测试 general.test.ts 精确验证了这些行为:多个Symbol("foo")依次编译为__foo____foo_2__foo_3Symbol()编译为__local__,而同一 Symbol 引用多次时别名保持一致(__bar__出现三次)。

这一机制让"同一张表在查询中出现多次"的自连接场景变得完全无脑安全,也正是 Grafast 的 dataplan-pg 在底层大量使用sql.identifier(this.symbol)生成表别名的原因(参见 grafast/dataplan-pg/src/steps/pgSelect.ts 等处)。

特殊字符与保留字处理

包含空格、引号或其他特殊字符的标识符同样会被安全转义,因为整个名称总是被双引号包裹,内部的双引号会被加倍转义:

import { sql } from "pg-sql2"; // 含空格的标识符被安全转义 const query = sql`SELECT * FROM ${sql.identifier("user data")}`; console.log(sql.compile(query).text); // SELECT * FROM "user data"
// 含特殊字符的标识符也被安全转义 const query = sql`SELECT * FROM ${sql.identifier('b"z')}`; console.log(sql.compile(query).text); // SELECT * FROM "b""z"

转义逻辑集中在escapeSqlIdentifier()(src/index.ts):把双引号(")和空字符(\0)统一替换为双引号对"",再用双引号包裹整个名称。这与 PostgreSQL 服务端处理带引号标识符的规则一致,源码注释说明其移植自 PostgreSQL 的fe-exec.c并经正则优化(比原实现快约 11 倍)。

保留字处理:由于字符串标识符一律加双引号输出,PostgreSQL 保留字(如selectfromorder等)作为标识符使用时会被当作普通名称而非关键字,天然规避了保留字冲突问题——这正是文档 Notes 中"Reserved SQL keywords are handled correctly through escaping"的底层原因。

深入底层:identifier 的节点模型与编译流程

pg-sql2 的所有 API 都返回统一的SQL节点对象,sql.identifier也不例外。理解节点模型有助于排查问题:

  • 字符串参数在调用时立即通过escapeSqlIdentifier()转义,生成RAW节点{ [$$type]: "RAW", t: '"foo"' }),多个字符串会被拼入同一段原始文本,之间以.连接。
  • Symbol 参数生成IDENTIFIER节点{ [$$type]: "IDENTIFIER", s: symbol, n: mangle 后的名字 }),见 src/index.ts。
  • 混合传参(如sql.identifier("public", Symbol("tbl")))会生成一个包含RAW节点与IDENTIFIER节点的QUERY节点组合。

到了sql.compile()阶段,遍历节点树时(src/index.ts):

  1. RAW节点原样输出;
  2. IDENTIFIER节点先去symbolToIdentifierMap 中查当前 Symbol 已分配的别名,没有则调用makeIdentifierForSymbol()现场分配并缓存;
  3. 由于别名只由安全字符组成,直接输出而无需再次转义。

编译结果textvalues即可直接交给pg等驱动执行;返回对象中还带有一个[$$symbolToIdentifier]的 Map(见 src/index.ts),用于查询 Symbol 到最终别名的映射关系。

注意事项与边界

  • 单个编译内一致性、跨编译非确定性:Symbol 标识符生成的字符串表示在同一次编译的查询内始终一致(同一 Symbol 多次出现会复用同一别名);但如果同一个sql.identifier(Symbol(...))片段被复用到多个查询中分别编译,各次编译得到的别名可能不同(因为 Symbol 每次都是新对象,且计数从 1 重新开始)。文档 Notes 中对此有明确说明。
  • 字符串标识符总会加引号:即使sql.identifier("users")这种完全合法的名称,输出也是"users"而非users。这在功能上无碍(PostgreSQL 中带引号与不带引号的标识符指向同一对象,前提是名字不含大写/特殊字符),但若你追求生成 SQL 的简洁性,需要注意这一行为。相比之下,Symbol 生成的别名因为字符安全而不加引号。
  • 务必与sql.value()分工:标识符(表名/列名/别名)用sql.identifier(),数据值(字面量)用sql.value()sql.literal()。把用户输入当标识符直接拼进sql模板、或把标识符当值传入占位符,都是典型的错误用法。
  • 不要用sql.raw()替代sql.raw()是绕过一切保护的逃生门,直接输出未经处理的动态文本,会彻底破坏 pg-sql2 的注入防护体系;几乎任何合法场景都能用sql.identifier()sql.value()的组合完成,详见 utils/website/pg-sql2/api/index.md 中的警告。

在真实项目中的典型应用

pg-sql2 是 Graphile Crystal 全家桶(Grafast、PostGraphile、pg-introspection 等)的 SQL 构建基石,sql.identifier()在其中被广泛使用。仅以 grafast/dataplan-pg 为例:

  • 为每个数据步骤生成表别名:this.alias = sql.identifier(this.symbol)(pgInsertSingle.ts、pgDeleteSingle.ts);
  • 动态拼装 schema/表/列限定名:sqlType: sql.identifier(...type.split("."))(codecs.ts);
  • 在过滤条件中安全引用属性列:sql${this.parent.alias}.${sql.identifier(attr)} = ...``(pgManyFilter.ts)。

这些场景的共同特点是:名称来自运行时数据(数据库 introspection 结果、用户传入的排序/过滤字段等),无法在编译期写死,但又绝不能信任其内容——正是sql.identifier()的设计目标。

相关 API 一览

sql.identifier()通常与以下 API 配合使用,完整清单见 utils/website/pg-sql2/api/index.md:

  • sql`...`— 模板字面量函数,构建查询主体;
  • sql.compile() — 将 SQL 片段编译为textvalues
  • sql.value() — 用占位符嵌入用户值(防注入);
  • sql.literal() — 安全时直接内联简单值,否则回退到sql.value()
  • sql.join() — 用分隔符拼接多个片段(常与sql.identifier组合构建动态列清单);
  • sql.symbolAlias() 与 sql.replaceSymbol() — 片段合并场景下的 Symbol 别名对齐与替换(例如在JOIN中把两个子查询的同一 Symbol 视为相同)。

总结:sql.identifier()用"字符串即转义、Symbol 即别名"的双轨设计,配合编译期的符号映射机制,为动态 SQL 中最容易出问题的标识符环节提供了既安全又灵活的标准答案。凡是需要把运行期名称引入查询的地方,都应优先想到它。

  • 后端
  • API网关

【免费下载链接】crystal

🔮 Graphile's Crystal Monorepo; home to Grafast, PostGraphile, pg-introspection, pg-sql2 and much more!

项目地址:https://gitcode.com/gh_mirrors/cry/crystal
点击查看免费下载

相关推荐

上一篇:使用 aws_codebuild_fleet 数据源查询 AWS CodeBuild 计算舰队信息
下一篇:5分钟实现Proxmox VE脚本自动化:GitLab CI/CD集成指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询