Files
u-tpl/README.md
绝尘 861d58d718 新增: u-tpl SQL 模板引擎完整实现
- Lexer/Parser/Executor 三阶段架构
- #{param} 参数化 + ${raw} 原样替换 + 白名单安全策略
- @if/@for/@tpl/@include/@namespace 控制流
- 表达式引擎: 比较、逻辑、nil 检查、len() 内置函数
- 支持 ?/$1/:1 多数据库占位符风格
- 零依赖,纯 Go 标准库实现
2026-04-01 00:27:50 +08:00

1014 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# u-tpl
高性能 Go SQL 模板引擎 — 为现代 SQL 模板管理设计AI 友好。
> u-tpl 不是 ORM不是查询构建器。它只做一件事**把模板渲染成安全的参数化 SQL**。
```
模板字符串 → u-tpl → SQL + Args → database/sql
```
> 不推荐Go 代码里拼 SQL 字符串(注入风险、难维护、无法分离参数)。
## 特性
- **安全参数化** — `#{param}` 自动生成占位符 + 收集参数,杜绝 SQL 注入
- **Go 风格控制流** — `@if(){}` / `@for()` / `else{}` 块结构,开发者零学习成本
- **受控的原样替换** — `${param}` 用于表名/列名,可配置白名单拦截非法值
- **多数据库占位符** — 同一模板适配 `?`(MySQL) / `$1`(PostgreSQL) / `:1`(Oracle)
- **零依赖** — 纯 Go 标准库实现,无第三方依赖
- **AI 友好** — `#{}`/`${}` 借鉴 MyBatis`@if`/`@for` 借鉴 GoLLM 训练数据覆盖率极高
- **高性能** — 解析一次 AST执行只需树遍历~500ns/op简单模板
## 快速开始
```go
package main
import (
"fmt"
"gitea.1216.top/lxy/u-tpl"
)
func main() {
engine := utpl.New()
tpl := engine.MustParse("user_by_id",
"SELECT id, name, email FROM users WHERE id = #{id} AND status = #{status}")
result, _ := tpl.Execute(map[string]any{
"id": 42,
"status": "active",
})
fmt.Println(result.SQL) // SELECT id, name, email FROM users WHERE id = ? AND status = ?
fmt.Println(result.Args) // [42 active] (示意输出)
// 直接用于 database/sql:
// rows, err := db.Query(result.SQL, result.Args...)
}
```
## 安装
```bash
go get gitea.1216.top/lxy/u-tpl
```
---
## 语法参考
### 概览
| 语法 | 用途 | 替换为 | Args |
|------|------|--------|------|
| `#{param}` | 安全参数 | `?` (或 `$1`) | 收集值 |
| `${param}` | 原样替换 | 值本身 | 不收集 |
| `@if(expr) { } else { }` | 条件判断/分支 | — | — |
| `@for(v, range list) { }` | 循环 | — | 每项收集 |
| `@tpl("x") { }` | 命名语句块 | — | — |
| `@include("path")` | 引入子模板 | 子模板内容 | 共享父模板变量 |
| `@namespace("x")` | 命名空间声明 | — | — |
| `# 注释` | 注释 | 空 | — |
> `#` 后跟 `{` 是安全参数(`#{param}`),否则是行注释(`# 这是注释`)。
语法设计遵循 Go 惯例:`@` 前缀标记模板指令,`#` 前缀标记参数和注释,`{ }` 块结构与 Go 一致。
### `#{param}` — 安全参数
最常用的语法。值通过数据库驱动参数化传递,**不会被拼入 SQL 字符串**。
```sql
-- 模板
SELECT * FROM users WHERE id = #{user_id}
-- 输入: {"user_id": 42}
-- SQL: SELECT * FROM users WHERE id = ?
-- Args: [42]
```
```sql
-- 多参数
SELECT * FROM orders
WHERE user_id = #{uid} AND status = #{status} AND amount > #{min_amount}
-- 输入: {"uid": 1, "status": "paid", "min_amount": 100.0}
-- SQL: SELECT * FROM orders WHERE user_id = ? AND status = ? AND amount > ?
-- Args: [1, paid, 100]
```
**适用场景**:所有 WHERE 条件值、LIMIT/OFFSET、INSERT/UPDATE 的值。
### `${param}` — 原样替换
值直接拼入 SQL 字符串,**不经过参数化**。适用于无法参数化的场景表名、列名、SQL 关键字)。
```sql
-- 模板
SELECT ${columns} FROM ${table} WHERE tenant_id = #{tid}
-- 输入: {"columns": "id, name", "table": "users", "tid": 100}
-- SQL: SELECT id, name FROM users WHERE tenant_id = ?
-- Args: [100]
```
> `${}` 存在注入风险,建议配合 [安全策略](#安全策略) 使用白名单。
### `@if(expr) { }` — 条件判断
根据条件决定是否拼接 SQL 片段。块结构与 Go 的 `if` 一致,支持多行和行内写法。
```sql
-- 可选 WHERE 条件
SELECT * FROM users WHERE 1=1
@if(name != "") {
AND name LIKE #{name}
}
@if(status != nil) {
AND status = #{status}
}
@if(min_age != nil && max_age != nil) {
AND age BETWEEN #{min_age} AND #{max_age}
}
```
```go
// 场景 1: 只传 name
tpl.Execute(map[string]any{"name": "%张%"})
// SQL: SELECT * FROM users WHERE 1=1 AND name LIKE ?
// Args: ["%张%"]
// 场景 2: 传 name + status
tpl.Execute(map[string]any{"name": "%张%", "status": "active"})
// SQL: SELECT * FROM users WHERE 1=1 AND name LIKE ? AND status = ?
// Args: ["%张%", "active"]
// 场景 3: 全条件
tpl.Execute(map[string]any{"name": "", "status": "active", "min_age": 18, "max_age": 60})
// SQL: SELECT * FROM users WHERE 1=1 AND status = ? AND age BETWEEN ? AND ?
// Args: ["active", 18, 60]
```
**else 分支(支持行内写法):**
```sql
ORDER BY ${sort_col} @if(desc) {DESC} else {ASC}
```
**多条件分支else if 链,同行分隔):**
```sql
@if(role == "admin") {
AND level >= #{admin_level}
} else if (role == "manager") {
AND level >= #{mgr_level}
} else {
AND level >= 1
}
```
### `@for(v, range list) { }` — 循环
遍历集合,生成重复 SQL 片段。`range` 关键字遵循 Go 语法。
```sql
-- 动态 IN 子句(尾部逗号自动修剪)
SELECT * FROM products
WHERE category_id IN (@for(id, range ids) {#{id}, })
-- 输入: {"ids": []int{1, 5, 12, 27}}
-- SQL: SELECT * FROM products WHERE category_id IN (?, ?, ?, ?)
-- Args: [1, 5, 12, 27]
```
> `@for` 和 `@tpl` 块输出末尾的逗号自动修剪(去除尾部空白后,最后一个字符是 `,` 则移除)。
> 如需自定义分隔逻辑,可用 `@for(i, v, range items) { ... }` 配合 `loop_index` / `loop_last` 变量(`i` 是索引,`v` 是值,与 Go `range` 一致)。
**循环内置变量:**
| 变量 | 类型 | 说明 |
|------|------|------|
| `loop_index` | int | 当前索引(从 0 开始) |
| `loop_first` | bool | 是否是第一个元素 |
| `loop_last` | bool | 是否是最后一个元素 |
### `# 注释` — 注释
行注释,`#` 之后到行末的内容在渲染时被忽略。
```sql
SELECT * FROM users
#
WHERE status = #{status} AND is_test = 0
```
### 表达式语言
条件表达式保持极简,覆盖 SQL 模板的实际需求:
```
变量访问 name user.name
nil 检查 name != nil status == nil
字符串比较 name != "" role == "admin"
数值比较 age >= 18 count < 100
逻辑运算 a != nil && b != "" a || b !flag
内置函数 len(ids) > 0 len(items) == 0
```
### `@tpl("x") { }` — 命名语句块
一个模板文件可以包含多个命名语句块,执行时按名称取用。
```sql
-- tpl/orders.tpl
@tpl("search") {
SELECT o.id, o.user_name, o.total_amount
FROM orders o
WHERE o.tenant_id = #{tenant_id}
@if(user_name != "") { AND o.user_name LIKE #{user_name} }
LIMIT #{limit}
}
@tpl("count") {
SELECT COUNT(*) FROM orders
WHERE o.tenant_id = #{tenant_id}
@if(user_name != "") { AND o.user_name LIKE #{user_name} }
}
@tpl("insert") {
INSERT INTO orders (user_name, total_amount, tenant_id)
VALUES (#{user_name}, #{total_amount}, #{tenant_id})
}
```
```go
tpl := engine.MustParse("orders", ordersTPL)
searchResult, _ := tpl.ExecuteBlock("search", map[string]any{
"tenant_id": 100, "user_name": "%张%", "limit": 20,
})
insertResult, _ := tpl.ExecuteBlock("insert", map[string]any{
"user_name": "张三", "total_amount": 99.9, "tenant_id": 100,
})
searchResult.SQL // SELECT o.id, ... LIMIT ?
searchResult.Args // [100, %张%, 20]
```
> 文件内要么全用 `@tpl` 块,要么不用,不能混用。有 `@tpl` 块时,顶级非指令文本返回 `ParseError`。
> 有 `@tpl` 块时,用 `ExecuteBlock("name", vars)` 按名取用;无 `@tpl` 块时,整个模板作为默认语句,用 `Execute()` 执行。
> 块名在文件内唯一,重复时返回 `ParseError`。
> 多个 `@tpl` 块间有重复片段时,用 `@include` 提取公共部分(见下一节)。
### `@include("path")` — 模块引入
在当前位置插入另一个模板文件的内容,复用公共片段。
```
tpl/
├── common/
│ ├── tenant.tpl # 租户过滤
│ └── pagination.tpl # 分页
├── orders.tpl
└── users.tpl
```
```sql
-- tpl/common/tenant.tpl
AND tenant_id = #{tenant_id}
@if(department_id != nil) { AND department_id = #{department_id} }
```
```sql
-- tpl/common/pagination.tpl
LIMIT #{limit} OFFSET #{offset}
```
```sql
-- tpl/orders.tpl
SELECT o.id, o.user_name, o.total_amount
FROM orders o
WHERE 1=1
@include("common/tenant")
@if(user_name != "") { AND o.user_name LIKE #{user_name} }
ORDER BY o.created_at DESC
@include("common/pagination")
```
```go
// 配置引入解析器 — 挂在 Engine 上,所有模板共享
engine := utpl.New(utpl.WithIncludeResolver(func(path string) (string, error) {
src, err := os.ReadFile("tpl/" + path + ".tpl")
return string(src), err
}))
// 之后所有 Parse 的模板都支持 @include
tpl := engine.MustParse("orders", ordersTPL)
result, _ := tpl.Execute(map[string]any{"tenant_id": 100, "limit": 20, "offset": 0})
```
**规则:**
- `@include` 在解析期展开,子模板与父模板共享变量,子模板中的 `#{}` 会追加到同一个 Args 列表
- 子模板只能包含纯 SQL 片段 + 控制流,不能包含 `@tpl` 块和 `@namespace` 声明
- 引入路径不含后缀,解析器自动查找 `.tpl` / `.sql`
- 不配置 `WithIncludeResolver` 时,遇到 `@include` 返回 `ParseError`
- 不支持循环引入,检测到时返回 `ParseError`
### `@namespace("name")` — 命名空间
当多个文件存在同名 `@tpl` 块时(如每个模块都有 `search` / `insert`),用 `@namespace` 显式声明命名空间来区分。
```sql
-- tpl/orders.tpl
@namespace("orders")
@tpl("search") {
SELECT * FROM orders WHERE 1=1
@include("common/tenant")
LIMIT #{limit}
}
@tpl("insert") {
INSERT INTO orders (user_name, tenant_id) VALUES (#{user_name}, #{tenant_id})
}
```
```sql
-- tpl/users.tpl
@namespace("users")
@tpl("search") {
SELECT * FROM users WHERE 1=1
@include("common/tenant")
LIMIT #{limit}
}
@tpl("insert") {
INSERT INTO users (name, email) VALUES (#{name}, #{email})
}
```
```go
tplOrders := engine.MustParse("orders", ordersTPL)
tplUsers := engine.MustParse("users", usersTPL)
// 跨命名空间 — 必须用 命名空间.块名
tplOrders.ExecuteBlock("orders.search", vars)
tplUsers.ExecuteBlock("users.search", vars)
// 同命名空间内 — 块名可省略前缀
tplOrders.ExecuteBlock("search", vars) // 等同于 "orders.search"
// 注意:短名只在同一个 Template 对象上有效,跨 Template 对象必须用全限定名
```
**规则:**
- `@namespace` 声明在文件顶部,可选,不写则无命名空间
- 命名空间是显式声明,不依赖文件路径或目录结构
- 同命名空间内短名(`"search"`)和全限定名(`"orders.search"`)等价,但短名只在同一个 Template 对象上有效
- 移动文件、重命名目录不影响模板引用
---
## 实战示例
### 条件搜索 + 排序 + 分页
```sql
-- 模板: order_search
SELECT o.id, o.user_name, o.total_amount, o.created_at
FROM orders o
WHERE o.tenant_id = #{tenant_id}
@if(user_name != "") {
AND o.user_name LIKE #{user_name}
}
@if(status != nil) {
AND o.status = #{status}
}
@if(min_amount != nil) {
AND o.total_amount >= #{min_amount}
}
@if(created_after != nil) {
AND o.created_at >= #{created_after}
}
@if(order_ids != nil && len(order_ids) > 0) {
AND o.id IN (@for(oid, range order_ids) {#{oid}, })
}
ORDER BY ${sort_col} @if(sort_desc) {DESC} else {ASC}
LIMIT #{limit} OFFSET #{offset}
```
```go
result, _ := tpl.Execute(map[string]any{
"tenant_id": 100,
"user_name": "%张%",
"status": "paid",
"min_amount": nil,
"created_after": nil,
"order_ids": []int64{2001, 2002},
"sort_col": "o.total_amount",
"sort_desc": true,
"limit": 20,
"offset": 0,
})
// SQL: SELECT o.id, o.user_name, o.total_amount, o.created_at
// FROM orders o
// WHERE o.tenant_id = ? AND o.user_name LIKE ? AND o.status = ?
// AND o.id IN (?, ?)
// ORDER BY o.total_amount DESC
// LIMIT ? OFFSET ?
// Args: [100, %张%, paid, 2001, 2002, 20, 0]
```
### 批量 INSERT
```sql
-- 模板: batch_insert
INSERT INTO users (name, email, status) VALUES
@for(u, range users) {
(#{u.name}, #{u.email}, #{u.status}),
}
```
```go
type User struct {
Name string
Email string
Status string
}
result, _ := tpl.Execute(map[string]any{
"users": []User{
{"张三", "zhangsan@example.com", "active"},
{"李四", "lisi@example.com", "active"},
},
})
// SQL: INSERT INTO users (name, email, status) VALUES (?, ?, ?), (?, ?, ?)
// Args: [张三, zhangsan@example.com, active, 李四, lisi@example.com, active]
```
### 动态 UPDATE
```sql
-- 模板: update_user
UPDATE users SET
@if(name != "") { name = #{name}, } # :
@if(email != "") { email = #{email}, } # :
@if(status != nil) { status = #{status}, } # : nil
updated_at = NOW()
WHERE id = #{id} AND tenant_id = #{tenant_id}
```
```go
// 只更新 status
tpl.Execute(map[string]any{
"name": "",
"email": "",
"status": "inactive",
"id": 42,
"tenant_id": 100,
})
// SQL: UPDATE users SET status = ?, updated_at = NOW() WHERE id = ? AND tenant_id = ?
// Args: [inactive, 42, 100]
```
### 跨数据库兼容
```go
// MySQL
engine := utpl.New(utpl.WithPlaceholderStyle(utpl.QuestionMark))
// 同一模板,输出: WHERE id = ? AND name = ?
// PostgreSQL
engine := utpl.New(utpl.WithPlaceholderStyle(utpl.DollarNumber))
// 同一模板,输出: WHERE id = $1 AND name = $2
```
---
## 最佳实践
### 1. 安全第一:默认 `#{}`,谨慎 `${}`
```sql
-- 好 — 值走参数化,不可能注入
WHERE id = #{user_id} AND status = #{status}
-- 差 — 值直接拼入 SQL
WHERE id = ${user_id} AND status = ${status}
```
**原则**:能用 `#{}` 就用 `#{}`。只有表名、列名、SQL 关键字等无法参数化的场景才用 `${}`
### 2. `${}` 必须配白名单
生产环境始终为 `${}` 配置安全策略,防止注入。
```go
engine := utpl.New(
utpl.WithRawPolicy(utpl.RawAllowlist(map[string][]string{
"table_name": {"users", "products", "orders"},
"sort_col": {"id", "name", "created_at", "amount"},
})),
)
```
不要用黑名单——攻击者总能找到你没想到的列名。
### 3. 排序方向用条件分支,不要参数化
ASC/DESC 是 SQL 关键字,多数数据库不支持参数化排序方向。
```sql
-- 好 — 关键字写在模板里
ORDER BY ${sort_col} @if(desc) {DESC} else {ASC}
-- 差 — 多数数据库不会正确处理参数化的 ASC/DESC
ORDER BY ${sort_col} #{direction}
```
### 4. WHERE 条件用 `1=1` 起手 + `@if` 追加
避免动态拼接 WHERE 关键字本身,减少模板复杂度。
```sql
-- 好 — 1=1 起手,每个条件独立判断
SELECT * FROM users WHERE 1=1
@if(name != "") { AND name LIKE #{name} }
@if(status != nil) { AND status = #{status} }
@if(min_age != nil) { AND age >= #{min_age} }
-- 差 — 需要处理 AND 的前缀问题
SELECT * FROM users
@if(name != "") { WHERE name LIKE #{name} }
@if(status != nil) { AND status = #{status} }
```
### 5. IN 子句空值防护
始终检查列表非空再拼接 IN 子句,避免生成无效 SQL。
```sql
-- 好 — 空列表时整个 IN 条件不出现
@if(ids != nil && len(ids) > 0) {
AND id IN (@for(id, range ids) {#{id}, })
}
```
> `@for` 对 nil 和空集合均不输出任何内容,不报错。但仍建议在 `@if` 中做前置检查,避免生成 `IN ( )` 语法。
```go
// 传入 nil 或空切片 → IN 子句不会出现
tpl.Execute(map[string]any{"ids": nil})
// SQL: SELECT * FROM users WHERE 1=1
```
### 6. 模板启动时解析,不要每次请求 Parse
`Parse` 有开销(~10μs应在服务启动时完成`Template` 对象可并发安全使用。
```go
// 好 — 启动时解析,存入 map 复用
var (
engine = utpl.New()
tplSearch = engine.MustParse("order_search", orderSearchSQL)
tplInsert = engine.MustParse("batch_insert", batchInsertSQL)
)
func SearchOrders(ctx context.Context, params map[string]any) (*utpl.Result, error) {
return tplSearch.Execute(params)
}
```
```go
// 差 — 每次请求都 Parse
func SearchOrders(params map[string]any) (*utpl.Result, error) {
tpl, _ := engine.Parse("order_search", orderSearchSQL) // 每次都解析
return tpl.Execute(params)
}
```
### 7. LIMIT 始终参数化,不要省略
无 LIMIT 的查询可能返回全表数据,造成性能问题和内存溢出。
```sql
-- 好 — LIMIT 参数化,调用方控制
SELECT * FROM users WHERE status = #{status} LIMIT #{limit}
```
```go
// 代码层提供默认值
if _, ok := params["limit"]; !ok {
params["limit"] = 100
}
```
### 8. 注释标注业务意图
模板注释帮助后续维护者(包括 AI理解 SQL 的业务目的。
```sql
-- 模板: 活跃用户消费排行
SELECT m.name, SUM(o.amount) as total
FROM members m
JOIN orders o ON m.id = o.member_id
WHERE m.status = 'active'
# 30
AND o.created_at >= #{start_time}
AND o.created_at < #{end_time}
GROUP BY m.id, m.name
ORDER BY total DESC
LIMIT #{limit}
```
### 9. 动态 UPDATE 用尾逗号模式
每个可选字段独立成块,末尾带逗号。最终 SQL 末尾的逗号由引擎自动修剪。
```sql
-- 好 — 尾逗号模式,字段增删不影响其他字段
UPDATE users SET
@if(name != "") { name = #{name}, }
@if(email != "") { email = #{email}, }
@if(status != nil) { status = #{status}, }
updated_at = NOW()
WHERE id = #{id}
```
### 10. 模板与业务代码分离
模板是配置,不是代码。不要在 Go 代码里拼 SQL 字符串。
#### 方案一Go embed推荐
模板放 `.sql` 文件Go 编译时嵌入,部署零依赖。
```
project/
├── tpl/
│ ├── order_search.tpl
│ ├── order_insert.tpl
│ └── user_update.tpl
└── main.go
```
```sql
-- tpl/order_search.tpl
SELECT o.id, o.user_name, o.total_amount
FROM orders o
WHERE o.tenant_id = #{tenant_id}
@if(user_name != "") { AND o.user_name LIKE #{user_name} }
@if(status != nil) { AND o.status = #{status} }
ORDER BY ${sort_col} @if(desc) {DESC} else {ASC}
LIMIT #{limit}
```
```go
package main
import (
_ "embed"
"gitea.1216.top/lxy/u-tpl"
)
//go:embed tpl/order_search.tpl
var orderSearchSQL string
//go:embed tpl/order_insert.tpl
var orderInsertSQL string
var (
engine = utpl.New()
tplSearch = engine.MustParse("order_search", orderSearchSQL)
tplInsert = engine.MustParse("order_insert", orderInsertSQL)
)
```
**优点**:编译时嵌入、无运行时 I/O、部署单二进制、IDE 支持语法高亮。
**后缀**`.tpl``.sql` 均可,`.tpl` 强调模板属性,`.sql` 便于 IDE 直接识别 SQL 语法。
#### 方案二:外部文件加载
模板放文件系统,支持运行时修改,适合需要热更新的场景。
```go
func LoadTemplates(dir string) (map[string]*utpl.Template, error) {
engine := utpl.New()
tpls := make(map[string]*utpl.Template)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
ext := filepath.Ext(name)
if ext != ".tpl" && ext != ".sql" {
continue
}
src, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
return nil, err
}
key := strings.TrimSuffix(name, ext)
tpls[key] = engine.MustParse(key, string(src))
}
return tpls, nil
}
```
```
tpl/
├── order_search.tpl → templates["order_search"]
├── order_insert.sql → templates["order_insert"]
└── user_update.tpl → templates["user_update"]
```
#### 方案三:集中注册 + 命名空间
模板多时,用注册函数统一管理。配合 `@namespace` + `@tpl`,一个文件管理一个领域。
```go
var templates map[string]*utpl.Template
func initTemplates() {
engine := utpl.New(utpl.WithIncludeResolver(loadFile))
templates = map[string]*utpl.Template{
"orders": engine.MustParse("orders", loadDir("tpl/orders.tpl")),
"users": engine.MustParse("users", loadDir("tpl/users.tpl")),
"products": engine.MustParse("products", loadDir("tpl/products.tpl")),
}
}
// 使用 — 命名空间.块名
templates["orders"].ExecuteBlock("orders.search", params)
templates["orders"].ExecuteBlock("orders.insert", params)
templates["users"].ExecuteBlock("users.search", params)
```
```sql
-- tpl/orders.tpl
@namespace("orders")
@tpl("search") {
SELECT * FROM orders WHERE 1=1 @include("common/tenant") LIMIT #{limit}
}
@tpl("insert") {
INSERT INTO orders (user_name, tenant_id) VALUES (#{user_name}, #{tenant_id})
}
```
#### 反模式
```go
// 差 — Go 代码里拼 SQL
sql := "SELECT * FROM orders WHERE 1=1"
if name != "" {
sql += " AND name LIKE '" + name + "'" // 注入风险
}
// 差 — 模板散落在各个 handler 里
func OrderHandler() {
tpl, _ := utpl.New().Parse("tmp", "SELECT ...") // 每次请求都解析
}
```
---
## API 参考
### 创建引擎
```go
engine := utpl.New() // 默认: ? 占位符
engine := utpl.New( // 自定义配置
utpl.WithPlaceholderStyle(utpl.DollarNumber),
utpl.WithRawPolicy(utpl.RawAllowlist(...)),
)
```
**选项:**
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `WithPlaceholderStyle(style)` | 占位符格式 | `QuestionMark` (`?`) |
| `WithRawPolicy(policy)` | `${}` 安全策略 | 无限制 |
| `WithIncludeResolver(fn)` | `@include` 路径解析器 | 无(遇到 `@include` 报错) |
| `WithStrictMode(bool)` | 未定义变量时是否报错 | `true`(报错) |
**占位符风格:**
| 常量 | 输出 | 适用数据库 |
|------|------|-----------|
| `QuestionMark` | `?` | MySQL, SQLite, SQL Server |
| `DollarNumber` | `$1`, `$2`, ... | PostgreSQL |
| `ColonNumber` | `:1`, `:2`, ... | Oracle仅位置占位符不支持命名参数 `:name` |
### 解析模板
```go
tpl, err := engine.Parse(name, source) // 返回错误
tpl := engine.MustParse(name, source) // 出错 panic
```
`Template` 对象是线程安全的,解析一次可并发使用。
### 执行模板
```go
// 无命名块 — 整个模板作为一个语句
result, err := tpl.Execute(map[string]any{
"id": 42,
"status": "active",
})
result.SQL // "SELECT * FROM users WHERE id = ? AND status = ?"
result.Args // []any{42, "active"}
// 有命名块 — 按名称执行
result, err := tpl.ExecuteBlock("search", map[string]any{
"tenant_id": 100, "limit": 20,
})
// 仅获取 SQL 字符串(调试用途,无 Args 无法直接执行)
sql, err := tpl.ExecuteString(map[string]any{"id": 42})
sql, err := tpl.ExecuteBlockString("search", map[string]any{"limit": 20})
```
### Result 结构
```go
type Result struct {
SQL string // 渲染后的 SQL含占位符
Args []any // 参数值(按占位符顺序)
}
```
### 错误处理
```go
result, err := tpl.Execute(vars)
if err != nil {
// ParseError — 模板语法错误,包含行号和列号
// ExecError — 执行时错误(如变量未定义)
// UnsafeRawError — ${} 值被安全策略拦截
}
```
> **变量未定义时默认返回 `ExecError`**fail-fast。如需宽松模式未定义变量输出空字符串可用 `WithStrictMode(false)`。
---
## 安全策略
`${}` 原样替换有注入风险。通过 `RawPolicy` 限制允许的值。
### 白名单模式(推荐)
```go
engine := utpl.New(
utpl.WithRawPolicy(utpl.RawAllowlist(map[string][]string{
"table_name": {"users", "products", "orders"},
"sort_col": {"id", "name", "created_at", "amount"},
})),
)
```
```go
// 合法 — 在白名单中
result, err := tpl.Execute(map[string]any{"table_name": "users"}) // OK
// 非法 — 不在白名单中
result, err := tpl.Execute(map[string]any{"table_name": "users; DROP TABLE users; --"})
// err = UnsafeRawError: raw substitution '${table_name}' value not in allowlist
```
### 黑名单模式
```go
engine := utpl.New(
utpl.WithRawPolicy(utpl.RawBlocklist(map[string][]string{
"sort_col": {"password", "secret_key", "token"},
})),
)
```
### 不配置策略
不配置 `RawPolicy` 时,`${}` 直接替换,不做任何拦截。仅适用于内部可信场景。
---
## 架构
```
模板源码
┌─────────┐ ┌─────────┐ ┌───────────┐
│ Lexer │ → │ Parser │ → │ Template │ 解析期(一次性)
│ 词法分析 │ │ AST 构建 │ │ (AST) │
└─────────┘ └─────────┘ └─────┬─────┘
Result{SQL, Args} ◄── ┌───────────┐ 执行期(每次调用)
│ Executor │
│ 树遍历执行 │
└───────────┘
```
- **解析期**:源码 → Lexer → Token 流 → Parser → AST。只做一次产出 `Template` 对象
- **执行期**AST + 变量 → Executor 遍历树 → 输出 SQL 字符串 + 收集参数
- **Template 线程安全**:解析后不可变,可并发 `Execute`
### 包结构
```
utpl/
├── engine.go # Engine, New(), Option
├── template.go # Template, Execute(), Result
├── safety.go # RawPolicy 配置
├── error.go # 错误类型
└── internal/
├── lexer.go # 词法分析器
├── parser.go # 递归下降解析器
├── executor.go # AST 执行器
├── node.go # AST 节点定义
├── expr.go # 表达式解析与求值
├── context.go # 变量上下文
├── placeholder.go # 占位符生成
├── builtin.go # 内置函数
└── include.go # @include 路径解析与展开
```
对外仅暴露根包 4 个文件,内部实现全部在 `internal/` 下。
---
## 与其他方案对比
| 特性 | u-tpl | text/template | squirrel | MyBatis |
|------|-------|---------------|----------|---------|
| 语言 | Go | Go | Go | Java/XML |
| 参数化 SQL | `#{}` 自动收集 | 不支持 | 需手动提取 Args | `#{}` |
| `${}` 原样替换 + 安全策略 | 内置白名单 | 无 | 无 | 无限制 |
| 控制流语法 | `@if(){}` `@for(){}` | `{{if}}` | Go 代码 | `<if>` `<foreach>` |
| Args 分离 | 自动 | 不支持 | 需手动 | 自动 |
| AI 生成友好度 | 高(类 Go + MyBatis | 低(管道语法) | 低(链式调用) | 中XML 冗长) |
| 零依赖 | 是 | 是 | 是 | 否 |
| 模板存储 | Go 字符串 / 文件 | Go 字符串 / 文件 | Go 代码 | XML 文件 |
**u-tpl 的定位**:当你的核心需求是「把模板渲染成带参数的 SQL」时u-tpl 比通用模板引擎更安全内置参数化比查询构建器更直观SQL 即代码)。
---
## 性能
基准测试(单核,中等复杂度模板 ~10 个节点):
| 场景 | 速度 | 说明 |
|------|------|------|
| 简单替换 (3 个 `#{}`) | ~500 ns/op | 200 万次/秒 |
| 条件 WHERE (3 个 if) | ~2 μs/op | 50 万次/秒 |
| 循环 IN (10 元素) | ~5 μs/op | 20 万次/秒 |
> 解析开销约 10μs/模板,仅发生一次。性能瓶颈通常在数据库 I/O不在模板引擎。
---
## 常见问题
**`#{}``${}` 怎么选?**
默认用 `#{}`(安全参数化)。只有表名、列名等无法参数化的场景才用 `${}`,并建议配置白名单。
**`@if` 语法和 Go 的 `if` 有什么区别?**
`@if` 是模板指令,用 `@` 前缀与 SQL 内容区分。语法结构(`{ }``else`)与 Go 完全一致,无额外学习成本。
**变量嵌套访问(如 `user.name`)支持吗?**
支持。`Execute` 的 map 值可以是嵌套 map模板中用 `user.name` 访问。
**模板从哪里加载?**
u-tpl 不管模板加载。你可以从文件、数据库、配置中心、嵌入资源中读取字符串后传给 `Parse`
**模板中可以用 Go 的 `text/template` 吗?**
可以,但不推荐用于 SQL。`text/template` 不区分「参数化值」和「原样替换」,无法产出 `SQL + Args` 组合。
---
## License
MIT