- 开发工具
- 代码生成
- 数据库
【免费下载链接】sqlc
Generate type-safe code from SQL
导读
在 sqlc 中,查询参数可以通过sqlc.arg()、sqlc.narg()、sqlc.slice()以及@name等 sqlc 专有语法书写,也可以在编译期由编译器根据模式(schema)推断可空性。internal/sql/named包是这条管线上的"参数模型层":它把参数的名字、可空性、是否为sqlc.slice()参数统一建模为Param,并用ParamSet把占位符编号与参数一一映射,供编译器的类型推断和代码生成使用。阅读本文后,你将理解 sqlc 的参数是如何在预处理阶段被重写为各引擎原生占位符的、可空性合并的优先级规则、不同方言下同名参数编号行为的差异,以及 MySQL 用户变量@var与 sqlc 命名参数@param的关键区别。本文以 internal/sql/named/CLAUDE.md 为主线,结合 param.go、param_set.go、preprocess.go 等源码展开。
包的定位:参数模型的单一事实来源
按照internal/sql/named/CLAUDE.md的说明,internal/sql/named包只负责建模一条查询语句的参数:它们的名字(name)、可空性(nullability),以及是否来自sqlc.slice()。它不再关心 sqlc 的任何语法细节——sqlc.arg()、sqlc.narg()、sqlc.slice()和@name都会在引擎解析器运行之前,由 internal/sql/preprocess 重写为原生占位符,而ParamSet正是在该包重写的过程中被构建出来的。
也就是说,包内没有任何 SQL 解析逻辑,它向上承接预处理器的重写结果,向下为编译器提供查询参数的类型推断输入,是整个 sqlc 参数处理链条中的"数据模型层"。包内文件结构如下:
internal/sql/named/ ├── CLAUDE.md # 包设计说明(本文主线) ├── param.go # Param 与可空性建模 ├── param_set.go # ParamSet 占位符编号映射 ├── param_test.go # Param 可空性合并测试 └── param_set_test.go# ParamSet.Add 编号分配测试Param:一个参数的完整画像
Param(param.go)代表查询的一个输入参数,它既可以来自位置参数(如$1),也可以来自命名参数运算符@param,还可以来自命名参数函数调用sqlc.arg(param)。其结构体由三个字段组成:
type Param struct { name string nullability nullability isSqlcSlice bool }name:用户可见的参数名;nullability:可空性位掩码(见下节);isSqlcSlice:是否为sqlc.slice()参数。
四种构造器
包提供了四种公开构造器,对应参数的不同来源:
| 构造器 | 来源 | 初始可空性 |
|---|---|---|
NewParam(name) | sqlc.arg()或@name | 未指定(nullUnspecified) |
NewUserNullableParam(name) | sqlc.narg() | 始终可空(nullable) |
NewSqlcSlice(name) | sqlc.slice() | 未指定,isSqlcSlice = true |
NewInferredParam(name, notNull) | 编译器根据 schema 推断 | inferredNotNull/inferredNull |
从源码可以看到,NewInferredParam根据notNull布尔值把可空性设为inferredNotNull或inferredNull,而NewSqlcSlice则只是把未指定可空性的参数标记为 slice 参数。
可空性的位掩码表示
nullability是一个int类型的位掩码,这正是"可以按位 OR 合并"设计的关键(param.go):
const ( nullUnspecified nullability = 0b0000 inferredNull nullability = 0b0001 inferredNotNull nullability = 0b0010 nullable nullability = 0b0100 notNullable nullability = 0b1000 )五种状态分别表示:未指定、推断为可空、推断为不可空、用户指定可空、用户指定不可空。由于每个状态独占一个 bit,任意两个来源的 nullability 都可以通过按位 OR 无损合并。
NotNull 的裁决顺序
Param.NotNull()(param.go)按照"用户指定优先于推断"的优先级链做出最终裁决:
- 用户指定不可空(
notNullable)→ 返回true; - 用户指定可空(
nullable)→ 返回false; - 推断不可空(
inferredNotNull)→ 返回true; - 推断可空(
inferredNull)→ 返回false; - 完全未指定 → 默认返回
false(可空),这与大多数数据库的默认行为一致。
也就是说,sqlc.narg()强制可空、推断为 NOT NULL 的参数遇到用户指定的可空性时以用户为准,用户的可空性要求永远压过编译器的推断。
mergeParam:无顺序依赖的可空性合并
mergeParam(a, b)(param.go)把两个"部分指定的"参数合并成一个完整参数:
- 名字:优先取
a的名字;若a为空则取b的(TestMergeParamName验证了"非空名字优先"的规则); - 可空性:直接按位 OR
a.nullability | b.nullability,因此两个来源到达的顺序不影响结果——这是TestMergeParamNullability中"再尝试Combine(b, a)应得到相同结果"这一断言的理论基础; - isSqlcSlice:只要任一方为 slice 参数即为 true。
param_test.go中的TestMergeParamNullability覆盖了关键场景,尤其是"推断与用户定义冲突"的情形:
| 合并场景 | 结果 |
|---|---|
| 未指定 + 推断不可空 | 不可空 |
| 未指定 + 推断可空 | 可空 |
| 未指定 + 用户可空 | 可空 |
| 推断可空 + 用户可空 | 可空 |
| 推断不可空 + 用户可空 | 可空(用户优先) |
ParamSet:占位符编号 ↔ 参数映射表
ParamSet(param_set.go)为单条语句维护占位符编号到Param的映射。其内部包含四张结构:
hasNamedSupport:该引擎是否支持命名参数;namedParams map[string]Param:当前跟踪的命名参数集合;namedLocs map[string][]int:每个名字出现过的位置列表;positionToName map[int]string:编号到名字的反向映射;argn:已检查过的位置参数计数。
使用方式
ps := named.NewParamSet(numbersAlreadyUsed, hasNamedSupport) n := ps.Add(named.NewParam("author_id")) // 返回占位符编号NewParamSet的第一个参数是"已经用掉的编号集合"(如用户手写的$1、$5),这些位置会被预填为无名参数,编号分配从 1 开始向后寻找空位(nextArgNum);第二个参数hasNamedSupport决定同名参数是否共享编号。
Add 的编号分配语义
Add(param_set.go)的分配策略直接体现方言差异:
- 支持命名参数(
hasNamedSupport == true):重复出现的同一个名字直接返回第一次分配到的编号(namedLocs[name][0]),即"同名共享一个编号"; - 不支持命名参数(
hasNamedSupport == false):每次出现都通过nextArgNum拿到新的编号,即"每次出现各占一个编号"。
param_set_test.go中的TestParamSet_Add用表格测试完整验证了这一行为:
- 命名集(
named):重复添加p1两次都返回 1,p2返回 2; - 非命名集(
unnamed):p1分别返回 1、2,p2返回 3、4——每次出现都递增; - 预填编号集(
populatedNamed,已用 1/2/4/5/6):p1返回 3,p2返回 7,且重复添加结果不变; - 预填编号的非命名集(
populatedUnnamed):p1返回 3、7,p2返回 8、9。
编译器读回的两个 API
编译器通过两个方法把ParamSet读回:
NameFor(number):返回某占位符编号对应的用户可见名字;FetchMerge(number, inferred):把编号对应的已记录参数与编译器推断出的默认参数合并,返回合并后的参数以及"它是否为命名参数"的布尔值。
FetchMerge(param_set.go)在编号不存在或对应无名参数时,直接返回传入的mergeP与false——这正是 resolve.go 中addUnknownParam的用法:用NewInferredParam(ref.name, false)作为默认值,合并后若isNamed为真则标记IsNamedParam。
方言差异:hasNamedSupport的由来
为什么会有hasNamedSupport这个开关?dialect.go 中的注释给出了答案:
// hasNamedSupport reports whether repeated uses of the same parameter name can // share a single placeholder. MySQL sends an argument per "?", so every // occurrence needs its own number. func (d Dialect) hasNamedSupport() bool { return d.Style != StyleQuestion }- MySQL、ClickHouse:采用
StyleQuestion(每个?单独传一个参数),因此hasNamedSupport == false,名字的每次出现各占一个编号; - PostgreSQL(
$1)、SQLite(?1)等编号方言:同名参数可以共享同一个编号,hasNamedSupport == true。
number函数(preprocess.go)正是据此分支:?方言从空集开始、按源码顺序把所有出现(含原生?与重写出的 sqlc 参数)统一编号;编号方言则保留用户手写的编号($1、$5等),用NewParamSet(numbs, d.hasNamedSupport())填平缺口后,再为sqlc.arg()/sqlc.narg()/sqlc.slice()分配编号。
预处理器如何构建 ParamSet
尽管本包不处理语法,理解ParamSet的产生过程有助于把握整个管线。internal/sql/preprocess在引擎解析器之前把 sqlc 语法重写为原生 SQL(详见 internal/sql/preprocess/CLAUDE.md),转换关系如下:
| sqlc 语法 | 重写为 |
|---|---|
sqlc.arg(name)/sqlc.narg(name) | 方言的原生占位符 |
sqlc.slice(name) | 包裹/*SLICE:name*/的占位符 |
sqlc.embed(table) | table.* |
@name | 原生占位符(@为 sqlc 语法的方言) |
各引擎的原生占位符与@name处理:
| 引擎 | 占位符 | @name |
|---|---|---|
| postgresql | $1 | sqlc 语法 |
| mysql | ? | 用户变量,原样保留 |
| sqlite | ?1 | sqlc 语法 |
在 preprocess.go 的Dialected流程中,number(d, occs)为每条语句构建Statement.Params(一个*named.ParamSet),param(occ)则根据 occurrence 的种类选择构造器:
func param(occ *occurrence) named.Param { switch occ.kind { case kindNarg: return named.NewUserNullableParam(occ.name) case kindSlice: return named.NewSqlcSlice(occ.name) default: return named.NewParam(occ.name) } }即:sqlc.narg()产生用户可空参数、sqlc.slice()产生 slice 参数、sqlc.arg()/@name产生未指定可空性的普通参数。语句预处理失败时也会构建一个空的ParamSet(NewParamSet(nil, d.hasNamedSupport())),保证下游调用方拿到的永远是有效对象(preprocess.go)。
编译器侧的回读:FetchMerge 的实际调用
FetchMerge在编译器中被广泛使用。resolve.go 在解析各类表达式时都会调用它,把预处理器记录的参数信息与编译器针对具体表达式推断出的默认参数合并:
limitOffset/limitCount:默认推断为"offset"/"limit",notNull = true,数据类型integer;- 未知参数(
addUnknownParam):默认推断为可空,数据类型any; - 二元表达式:默认推断
"||"为text类型等。
合并后的p.NotNull()、p.IsSqlcSlice()、p.Name()分别决定最终参数的不可空性、slice 标记与用户可见名字(resolve.go)。而在 parse_core.go 中,FetchMerge被用于合并 schema 列推断出的可空性:
if param, isNamed := params.FetchMerge(p.Number, named.NewInferredParam(col.Name, p.NotNull)); isNamed { ... col.IsSqlcSlice = param.IsSqlcSlice() }最终,analyze.go 把IsSqlcSlice透传到分析结果,供代码生成阶段决定参数是否以 slice(展开为IN (...)多值)形式处理。
MySQL@variable与 sqlc@param的边界
internal/sql/named/CLAUDE.md特别强调了一个易混淆点:@name只有在预处理器的方言声明它是 sqlc 语法时才是 sqlc 语法。在 dialect.go 中,MySQL 的AtSign被设为false,而 PostgreSQL、SQLite 为true(GoogleSQL 虽未在dialects表中列出,但按 CLAUDE.md 说明同样把@name视为命名参数)。
于是同一个写法在不同引擎下含义截然不同:
-- PostgreSQL 中,@ 是 sqlc 语法: SELECT * FROM users WHERE id = @user_id -- 预处理后为: SELECT * FROM users WHERE id = $1 -- MySQL 中,@ 是用户变量: SELECT * FROM users WHERE id != @user_id -- 保持不变: SELECT * FROM users WHERE id != @user_idMySQL 场景下,@user_id会被转换为ast.VariableExpr,原样到达解析器,绝不参与 sqlc 的参数重写。这一设计避免了 sqlc 语法与 MySQL 原生用户变量语义的冲突——在 MySQL 中请改用sqlc.arg('user_id')来书写命名参数。
小结与扩展阅读
internal/sql/named以极小的表面积(两个核心类型、四种构造器、两个回读 API)完成了 sqlc 参数建模的全部工作:
- Param用位掩码可空性统一了"用户指定"与"编译器推断"两种来源,且用户优先级更高;
- mergeParam的按位 OR 语义让合并顺序无关,保证可空性裁决确定可复现;
- ParamSet通过
hasNamedSupport优雅地适配了?方言与编号方言在"同名参数是否共享编号"上的本质差异; - 预处理器构建、编译器消费,
FetchMerge/NameFor是两者间的唯二接口。
想深入验证以上行为,可以直接运行本包的单元测试:
go test ./internal/sql/named -v go test ./internal/sql/preprocess -update # 重新生成预处理 golden 文件(仅开发时使用)进一步阅读:预处理器的完整设计见 internal/sql/preprocess/CLAUDE.md,编译器对参数的消费见 internal/compiler/resolve.go 与 internal/compiler/parse_core.go,端到端测试语料位于 internal/endtoend/testdata(如sqlc_arg、sqlc_narg、sqlc_slice等目录)。
- 开发工具
- 代码生成
- 数据库
【免费下载链接】sqlc
Generate type-safe code from SQL
相关推荐
libpqxx 语句参数(Statement Parameters)深度指南:从 `$1` 占位符到动态参数列表
libpqxx 语句参数(Statement Parameters)深度指南:从 $1 占位符到动态参数列表 导读 本文以 libpqxx 7.7.3 官方文档
网络通信后端@babel/plugin-transform-parameters 深度解析:默认参数与 Rest 参数到 ES5 的编译实现
@babel/plugin transform parameters 深度解析:默认参数与 Rest 参数到 ES5 的编译实现 本指南以 packages/b
编译器开发工具Meteor TypeScript 包深度解析:从编译管线到特性边界
Meteor TypeScript 包深度解析:从编译管线到特性边界 导读 本篇文章围绕 Meteor 官方 typescript 编译器插件包展开,讲解它在
后端前端开发工具移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考