這篇整理 sqlc v1.31.1 的用法。範例沿用 10.1 database/sql:連線池、查詢結果的讀取與交易 定義的 customers 與 orders 兩張表,驅動用 10.2 pgx:PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入 的 pgx v5。在 1.17 應用程式存取資料庫的工具分層 的分類裡,sqlc 屬於從 SQL 產生程式碼:SQL 由人手寫在 .sql 檔裡,sqlc 讀 schema 與這些 SQL,產生呼叫它們的 Go 函式,以及參數與結果的 struct。

sqlc 是開發時執行的命令列工具,不是程式在執行時匯入的函式庫;執行時的程式碼只依賴它產生出來的檔案與 pgx。產生出來的 store 套件放在專案的 Go 模組裡,呼叫端以 import "<go.mod 的 module 名稱>/sqlcdemo/store" 引用。

設定檔與目錄

 1migrations/                 # 專案的 migration 目錄,與 sqlcdemo/ 同一層
 2├── 001_init.up.sql
 3└── 001_init.down.sql
 4sqlcdemo/
 5├── sqlc.yaml
 6├── query/
 7│   └── orders.sql      # 手寫的查詢
 8└── store/              # sqlc 產生,不手動修改
 9    ├── db.go
10    ├── models.go
11    └── orders.sql.go
 1version: "2"
 2sql:
 3  - engine: "postgresql"
 4    schema: "../migrations"   # 建表的 SQL;指向 migration 目錄
 5    queries: "query"          # 手寫查詢所在的目錄
 6    gen:
 7      go:
 8        package: "store"
 9        out: "store"            # 產生的程式碼放哪裡
10        sql_package: "pgx/v5"   # 產生的程式碼呼叫 pgx 的原生介面

不寫 sql_package 時,產生的是呼叫 database/sql 的程式碼。要和 GORM 在同一個交易裡執行就選它,見 10.7 Go 專案的資料庫存取工具選型:GORM、sqlc 與 pgx 的組合〈混用時共用同一個交易〉。

schema 指向專案的 migration 目錄(資料表結構的版本化變更檔,見 Schema Migration),sqlc 依序讀裡面的檔案、算出最終的資料表結構。它認得幾種 migration 工具的檔名慣例,例如 golang-migrate 的 001_init.up.sql 與 001_init.down.sql 放在同一個目錄時,只讀 up 那一份;把 down 也讀進去的話,DROP TABLE 會讓後面的查詢全部找不到表。schema 的權威因此留在 migration,sqlc 只是讀它。

查詢檔與註記

每一段查詢上方的註記(query annotation)決定產生的函式名稱與回傳形狀:

 1-- name: ListOrdersByCustomer :many
 2SELECT id, customer_id, amount, note, ordered_at
 3FROM orders
 4WHERE customer_id = $1
 5ORDER BY id;
 6
 7-- name: GetCustomer :one
 8SELECT id, name, email FROM customers WHERE id = $1;
 9
10-- name: CreateCustomer :one
11INSERT INTO customers (name, email) VALUES ($1, $2)
12RETURNING id, created_at;
13
14-- name: TotalByCustomer :many
15SELECT c.name, sum(o.amount) AS total
16FROM customers c JOIN orders o ON o.customer_id = c.id
17GROUP BY c.name
18ORDER BY c.name;
註記產生的函式回傳
:one一個 struct;查不到時回傳 pgx.ErrNoRows
:manystruct 的 slice
:exec只回傳錯誤
:execrows影響的列數與錯誤
:copyfrom用 10.2 pgx:PostgreSQL 驅動的原生介面、連線池與 COPY 大量寫入 介紹的 COPY 協定批次寫入

產生指令在設定檔所在的目錄執行:

1# 不安裝,直接執行指定版本;第一次要下載並編譯,需要一兩分鐘
2go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1 generate
3
4# 安裝成執行檔之後,每次產生只要不到一秒;執行檔放在 $(go env GOPATH)/bin,那個目錄要在 PATH 裡
5go install github.com/sqlc-dev/sqlc/cmd/sqlc@v1.31.1
6sqlc generate

sqlc v1.31.1 要求 Go 1.26 以上來編譯它自己;手上的 Go 版本較舊時,Go 會依 GOTOOLCHAIN 的預設設定自動下載符合的工具鏈,這不影響專案本身用的 Go 版本。版本號寫在指令裡,團隊每個人產生的程式碼才會一致;產生出來的檔案開頭也會記下產生它的 sqlc 版本。

產生出來的程式碼

ListOrdersByCustomer 產生的函式就是 10.1 database/sql:連線池、查詢結果的讀取與交易 讀多列的標準寫法,只是由工具寫出來:

 1func (q *Queries) ListOrdersByCustomer(ctx context.Context, customerID int64) ([]Order, error) {
 2	rows, err := q.db.Query(ctx, listOrdersByCustomer, customerID)
 3	if err != nil {
 4		return nil, err
 5	}
 6	defer rows.Close()
 7	var items []Order
 8	for rows.Next() {
 9		var i Order
10		if err := rows.Scan(&i.ID, &i.CustomerID, &i.Amount, &i.Note, &i.OrderedAt); err != nil {
11			return nil, err
12		}
13		items = append(items, i)
14	}
15	if err := rows.Err(); err != nil {
16		return nil, err
17	}
18	return items, nil
19}

參數與回傳型別依查詢決定:查詢選的欄位剛好是一整張表時,回傳那張表對應的 struct(Order,定義在 models.go);選了部分欄位或含運算結果時,另外產生一個 XxxRow struct(TotalByCustomer 回傳 []TotalByCustomerRow,sum(o.amount) 推成 int64);參數超過一個時產生一個 XxxParams struct(CreateCustomerParams{Name, Email})。呼叫端的寫法如下:

1pool, err := pgxpool.New(ctx, dsn)
2q := store.New(pool)
3
4orders, err := q.ListOrdersByCustomer(ctx, 1)
5totals, err := q.TotalByCustomer(ctx)
1[{Name:佳穎 Total:800} {Name:宗翰 Total:250}] <nil>

store.New 接受的是 DBTX 這個介面(Exec、Query、QueryRow 三個方法),*pgxpool.Pool 與 pgx.Tx 都符合。交易裡執行時用 q.WithTx(tx) 取得一個綁在那個交易上的 *Queries,交易的開始與收尾仍然照 pgx 那一篇的寫法由程式負責。

產生時擋下的錯誤

sqlc 在產生時解析每一段查詢、對照 schema,欄位或表名寫錯就不產生程式碼。把 GetCustomer 那一行的 name 打成 nmae 再產生一次:

1# package store
2query/orders.sql:8:12: column "nmae" does not exist

同樣的錯字在 sqlx 或 pgx 裡是一個要等到執行那一行才會出現的錯誤。換成 sqlc 之後,schema 改了欄位名稱,重新產生時所有用到舊名稱的查詢一起報錯,連帶呼叫端用到的 struct 欄位也跟著改名,Go 編譯器接著指出每一個要改的呼叫位置。

NULL 與型別對應

pgx v5 的預設對應裡,可以是 NULL 的欄位與 timestamptz 都產生成 pgx 的 pgtype 型別:

1type Order struct {
2	ID         int64
3	CustomerID int64
4	Amount     int32
5	Note       pgtype.Text        // 可以是 NULL 的 text
6	OrderedAt  pgtype.Timestamptz
7}

pgtype 型別讓產生的 struct 依賴 pgx 的套件,放進 domain 型別或 JSON 回應前都要轉換一次。兩個設定改掉這個預設,加在前面 sqlc.yaml 的 gen.go 底下、和 sql_package 同一層:

1gen:
2  go:
3    emit_pointers_for_null_types: true   # 可以是 NULL 的欄位改成指標,例如 *string
4    overrides:
5      - db_type: "timestamptz"
6        go_type: "time.Time"             # NOT NULL 的 timestamptz 對應成 time.Time
1type Order struct {
2	ID         int64
3	CustomerID int64
4	Amount     int32
5	Note       *string
6	OrderedAt  time.Time
7}

依 db_type 寫的 override 預設只套用在 NOT NULL 的欄位;可以是 NULL 的 timestamptz 欄位要另寫一條加上 nullable: true 的 override(例如 go_type: {type: "time.Time", pointer: true}),否則仍然是 pgtype.Timestamptz。

integer 欄位產生的是 int32,因為 PostgreSQL 的 integer 就是 32 位元;要 int64 的話建表時用 bigint,或同樣用 overrides 指定。

選擇性的篩選條件

sqlc 產生的函式裡,SQL 文字在產生時就固定了,「有給最低金額才加這個條件」這類依輸入增減的條件,寫法是把條件寫成「參數是 NULL 時永遠成立」。把下面這段加進 query/orders.sql 再重新產生:

1-- name: SearchOrders :many
2SELECT id, amount FROM orders
3WHERE customer_id = $1
4  AND (sqlc.narg(min_amount)::int IS NULL OR amount >= sqlc.narg(min_amount))
5ORDER BY id;

sqlc.narg 宣告一個可以是 NULL 的具名參數。在開了 emit_pointers_for_null_types 的設定下,產生的參數 struct 裡它是指標(預設是 pgtype.Int4):

1type SearchOrdersParams struct {
2	CustomerID int64
3	MinAmount  *int32 // nil 代表不篩選
4}
1MinAmount = nil  → [{101 300} {102 500}]
2MinAmount = 400  → [{102 500}]

這個寫法在條件只有兩三個時清楚好讀。條件多到十幾個、排序欄位也由使用者決定時,OR 堆疊的查詢讀起來吃力,資料庫為它選的執行計畫也可能不是最好的;那類查詢交給查詢建構器(Query Builder)或 ORM 的條件串接,和 sqlc 並存(混用的方式見 10.7 Go 專案的資料庫存取工具選型:GORM、sqlc 與 pgx 的組合)。

sqlc 帶進來的工作

sqlc 把 SQL 的錯誤提前到產生時,代價是多了一個產生步驟:

  • schema 或查詢改了要重新產生,產生的檔案通常提交進版本控制,審查時看得到產生結果的差異。
  • CI 裡跑一次產生,再確認工作目錄沒有差異,可以擋下「改了 SQL 卻忘了重新產生」的提交。
  • 產生的程式碼不手動修改;要改行為就改 SQL、設定檔,或在產生的函式外面再包一層。

不想要這個步驟、而查詢以單表增刪查改為主的專案,看的是 10.5 GORM:model、關聯載入與 ORM 送出的 SQL。