sqlc:Go 里写 SQL 但拿到类型安全的 generated code

起因

Go 写数据库代码两条路:

  • 用 ORM(GORM):自动 query 但隐藏 SQL;复杂 join 难表达
  • 手写 sql.DB:每条 query 自己写、scanner 自己 typed、容易写错

sqlc 是另一条路:写纯 SQL,工具生成完全类型化的 Go 函数。
两者优点都有:SQL 完全可控 + Go 调用类型安全。

解决方案

1. 装

go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# 或 brew install sqlc / Docker

2. 配 sqlc.yaml

version: "2"
sql:
  - engine: "postgresql"
    queries: "db/query.sql"
    schema: "db/schema.sql"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"           # 或 "database/sql"

3. 写 schema.sql

-- db/schema.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email TEXT NOT NULL UNIQUE,
    nickname TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE posts (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    published_at TIMESTAMPTZ
);

4. 写 query.sql

-- db/query.sql

-- name: GetUser :one
SELECT * FROM users WHERE id = $1;

-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;

-- name: ListUsers :many
SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2;

-- name: CreateUser :one
INSERT INTO users (email, nickname)
VALUES ($1, $2)
RETURNING *;

-- name: UpdateUserNickname :exec
UPDATE users SET nickname = $2 WHERE id = $1;

-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;

-- name: ListUserPosts :many
SELECT p.*, u.nickname AS author_nickname
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1
ORDER BY p.published_at DESC
LIMIT $2 OFFSET $3;

-- name: CountPostsByUser :one
SELECT count(*) FROM posts WHERE user_id = $1;

-- name: X :one|many|exec|execrows 告诉 sqlc 生成什么类型函数:

  • :one 返回单行
  • :many 返回多行
  • :exec 不返回(INSERT/UPDATE/DELETE)
  • :execrows 返回影响行数

5. 生成

sqlc generate

internal/db/ 下生成:

internal/db/
├── db.go         # 接口 + Queries struct
├── models.go     # 表 → Go struct
└── query.sql.go  # 每个 query → Go 函数

models.go

type User struct {
    ID        int64
    Email     string
    Nickname  string
    CreatedAt time.Time
}

type Post struct {
    ID          int64
    UserID      int64
    Title       string
    Body        string
    PublishedAt pgtype.Timestamptz
}

query.sql.go

const getUser = `-- name: GetUser :one
SELECT id, email, nickname, created_at FROM users WHERE id = $1
`

func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) {
    row := q.db.QueryRow(ctx, getUser, id)
    var u User
    err := row.Scan(&u.ID, &u.Email, &u.Nickname, &u.CreatedAt)
    return u, err
}

类型完全推断自 schema。

6. 用

import (
    "context"
    "github.com/jackc/pgx/v5/pgxpool"
    "myapp/internal/db"
)

func main() {
    ctx := context.Background()
    pool, _ := pgxpool.New(ctx, "postgresql://localhost/myapp")
    defer pool.Close()

    queries := db.New(pool)

    // 创建
    user, err := queries.CreateUser(ctx, db.CreateUserParams{
        Email: "[email protected]",
        Nickname: "Alice",
    })

    // 查
    u, _ := queries.GetUserByEmail(ctx, "[email protected]")

    // 列表
    posts, _ := queries.ListUserPosts(ctx, db.ListUserPostsParams{
        UserID: u.ID,
        Limit:  20,
        Offset: 0,
    })

    for _, p := range posts {
        fmt.Println(p.Title, p.AuthorNickname)
    }
}

写错列名 / 类型 / 参数 → 编译报错。重构 schema 后 sqlc generate
所有不兼容的 query 调用都立刻被编译器抓出。

7. JOIN 出来的"虚拟表"自动生成 struct

-- name: ListUserPosts :many
SELECT p.*, u.nickname AS author_nickname
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1;

生成:

type ListUserPostsRow struct {
    ID              int64
    UserID          int64
    Title           string
    Body            string
    PublishedAt     pgtype.Timestamptz
    AuthorNickname  string
}

func (q *Queries) ListUserPosts(...) ([]ListUserPostsRow, error) { ... }

新结构体自动产生,不需要自己定义 DTO。

8. transaction

tx, err := pool.Begin(ctx)
defer tx.Rollback(ctx)

qtx := queries.WithTx(tx)

user, err := qtx.CreateUser(ctx, ...)
_, err = qtx.CreateProfile(ctx, ...)

return tx.Commit(ctx)

WithTx(tx) 返回绑定到事务的 queries 实例。所有调用都在同事务。

9. 跑迁移

sqlc 不管迁移。配合 goose /
golang-migrate /
atlas

goose -dir migrations postgres "postgresql://..." up

migrations/0001_init.sql

-- +goose Up
CREATE TABLE users (...);

-- +goose Down
DROP TABLE users;

sqlc 的 schema.sql 通常是迁移结果的"快照"(开发时方便)。
生产以 migration 序列为准。

10. 动态 query

sqlc 主要服务于"静态 SQL"。动态 WHERE / ORDER BY 灵活性差。
解决:

  • 简单分页 / 排序:参数化 LIMIT $1 OFFSET $2
  • 可选 filter:WHERE (@email::text IS NULL OR email = @email)
  • 真正动态:用 squirrel / goqu 等 query builder,sqlc 处理静态部分

与 GORM / ent 对比

GORM ent sqlc
哲学 ORM 全自动 schema-first ORM 写 SQL,生成 type-safe 代码
学习曲线 极低(你已经会 SQL)
性能 中(reflection) 高(直接 SQL)
复杂 JOIN 难表达 自然写 SQL
类型安全
migration 内置 内置 需配合工具

我的取向:业务大量 SQL → sqlc;CRUD 简单 → GORM 也行;schema 变化
频繁 + 业务复杂 → ent。

效果

我们 5 万行 Go 代码用 sqlc 替代手写 sql.DB.Query:

  • DB 相关 bug 减少 80%(编译期捕获)
  • 重构 schema 时编译器告诉所有要改的地方
  • 团队新人 onboarding:会 SQL 就能写,不需要学 ORM 语法
  • 性能比 GORM 快 ~30%(无 reflection / 直接 prepared statement)

踩过的坑

  1. nullable 列变 pgtype:column NULL 在 Go 是 pgtype.Text /
    pgtype.Int4 等,不是 string / int。
    String.Valid 字段判 null。
    SET sqlc.go.emit_pointers_for_null_types = true 改用 *string 等。

  2. 改 schema 后忘 sqlc generate:编译失败但不知道为啥。
    sqlc generate 加进 go generate + Makefile / justfile。

  3. 复杂 query plan 看不见:写了个 SQL 跑很慢,没人在 ORM 层
    优化。EXPLAIN ANALYZE 是基本功 —— ORM 怎么也帮不上忙。

  4. time.Time vs pgtype.Timestamptz:默认 timestamptz 列生成
    pgtype.TimestamptzTime 字段拿值,Valid 判 null。
    emit_exact_table_names = true 让生成名跟列对应。

  5. JOIN 列名冲突:两表都有 id 列 → 生成 struct 字段冲突。
    SQL 里手动 alias:SELECT p.id AS post_id, u.id AS user_id ...

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。