Chuyển đến nội dung
2.1 Chuyển store sang sqlc

2.1 Chuyển store sang sqlc

Chuyển store sang sqlc

Giai đoạn 2 viết mọi query của database bằng tay: một chuỗi SQL trong Go, một lời gọi pgx, và một Scan chép từng cột vào field của struct. Giai đoạn 2.1 chuyển các query đó sang file .sql và để một công cụ tên là sqlc viết code Go chạy chúng. Những gì người gọi nhìn thấy không đổi: cùng các interface, cùng các test, cùng quy tắc tenant. Thứ thay đổi là ai viết phần nhàm chán, và lỗi được bắt vào lúc nào.

Bạn sẽ học được gì

  • sqlc đọc gì, viết ra gì, và kiểm tra gì trước khi code của bạn chạy.
  • Cách giữ quy tắc tenant khi SQL nằm trong file thay vì trong Go.
  • Cách viết các filter tùy chọn trong một query mà nội dung không bao giờ đổi.
  • sqlc dừng ở đâu, và phải làm gì khi một query không vừa.
  • Cách tìm một query trong trace theo tên của nó.

Các từ dùng trong bài này

TừÝ nghĩa
Code generationMột công cụ viết source code giúp bạn từ một đầu vào khác. Bạn commit kết quả và không bao giờ sửa tay.
sqlcMột code generator. Đầu vào: schema và các query SQL của bạn. Đầu ra: các hàm Go có kiểu rõ ràng để chạy những query đó.
SchemaHình dạng của database: bảng, cột, kiểu, ràng buộc. Schema của hango là các goose migration trong migrations/.
ParameterMột giá trị được gửi tới Postgres tách riêng khỏi phần chữ SQL, viết là $1 hoặc @name. Postgres không bao giờ trộn nó vào SQL, nên nó không thể gây SQL injection.
NULL“Không có giá trị” của SQL. NULL = NULL không đúng, nên bạn kiểm tra bằng is null.
Row structStruct Go mà sqlc sinh ra để chứa một dòng kết quả của một query.
Escape hatchMột query viết tay vì công cụ không diễn đạt được nó, kèm một comment nói lý do.
StaleCode do sqlc sinh không còn khớp với đầu vào của nó, vì ai đó đã đổi một query hoặc một migration mà không generate lại.

Vì sao đổi thứ đang chạy tốt

Giai đoạn 2 chọn SQL viết tay để mọi query, và predicate tenant bên trong nó, đều nhìn thấy được trong Go. Cách đó đã chạy tốt. Nhưng nó cũng có những cái giá tăng lên theo từng bảng:

  • Một query chỉ được kiểm tra khi một test chạy nó. Một cột viết sai chính tả, một kiểu sai, hoặc một Scan có thứ tự cột sai vẫn compile bình thường và chỉ lỗi lúc chạy.
  • Mỗi query lặp lại cùng danh sách cột và cùng lời gọi Scan. Thêm một cột nghĩa là phải đi tìm từng cái.
  • Các filter tùy chọn được dựng bằng cách nối chuỗi, nên đoạn SQL thật sự chạy phụ thuộc vào request.

Giai đoạn 4 thêm agent, session và message, và sẽ sao chép bất kỳ pattern nào store đang có. Chuyển năm file query bây giờ rẻ hơn chuyển mười lăm file sau này. Đó cũng là lý do giai đoạn này tồn tại: hango là một dự án học tập, và sqlc đáng để học trên một codebase thật.

Phiên bản nhỏ nhất

Bạn viết một query, kèm một comment đặt tên cho nó và nói nó trả về bao nhiêu dòng:

-- name: GetUser :one
select * from users where tenant_id = @tenant_id and id = @id;

sqlc đọc bảng users từ các migration, suy ra @tenant_id và @id là uuid và select * trả về sáu cột, rồi viết ra đoạn này:

type GetUserParams struct {
	TenantID uuid.UUID `db:"tenant_id"`
	ID       uuid.UUID `db:"id"`
}

func (q *Queries) GetUser(ctx context.Context, arg GetUserParams) (User, error) {
	row := q.db.QueryRow(ctx, getUser, arg.TenantID, arg.ID)
	var i User
	err := row.Scan(&i.ID, &i.TenantID, &i.Email, &i.Role, &i.Status, &i.CreatedAt)
	return i, err
}

Đó chính là code mà giai đoạn 2 đã viết bằng tay. Nếu query nhắc tới một cột không tồn tại, sqlc generate thất bại và không có Go nào được viết ra. Nếu một migration về sau bỏ một cột mà query dùng, generate lại cũng thất bại theo cách đó. Lỗi được bắt ngay tại bàn làm việc của bạn, không phải trong một test hay trên production.

Hậu tố sau tên nói hình dạng cần trả về: :one cho một dòng, :many cho một slice, :exec cho không có dòng nào.

Cách hango được nối dây

Các file

migrations/*.sql              schema, read by sqlc (only the goose Up section)
sqlc.yaml                     settings: where to read, where to write, type choices
internal/store/pg/queries/    the queries, one file per table
internal/store/pg/*.sql.go    generated, one per query file
internal/store/pg/models.go   generated, one struct per table
internal/store/pg/db.go       generated, the Queries type and the DBTX interface
internal/store/pg/user.go …   hand-written, thin: scope in, store types out

Chạy make generate sau khi đổi một query hoặc một migration, rồi commit kết quả. make sqlc-check thất bại nếu code đã commit bị stale.

sqlc chạy bằng go run github.com/sqlc-dev/sqlc/cmd/[email protected], được ghim trong Makefile. Nó không nằm trong go.mod. Code do sqlc sinh chỉ import pgx, thứ hango vốn đã dùng, nên sqlc không bao giờ trở thành một phần của binary. Bạn tự kiểm tra được: go list -deps ./cmd/hango | rg sqlc không in ra gì. Lần chạy đầu tiên phải compile sqlc, mất vài phút vì nó chứa parser Postgres thật. Parser đó là code C, nên lần chạy đầu cần cgo và một C compiler (Xcode command line tools trên macOS, build-essential trên Debian). Các lần sau được cache.

Code do sqlc sinh nằm trong package pg

sqlc viết thẳng vào internal/store/pg, không phải một package riêng. Có hai lý do. Plan giới hạn hango ở hai mươi package, và một package pg/db sẽ tốn một suất cho code do sqlc sinh. Quan trọng hơn, pg là package duy nhất được phép import pgx, và một method do sqlc sinh cần một pgx connection (một DBTX), nên code ngoài pg không có cách nào gọi nó. Các method do sqlc sinh là exported, nên điều này dựa vào ranh giới import chứ không phải khả năng hiển thị. Quy tắc tenant từ giai đoạn 2 phụ thuộc vào ranh giới đó, và storeboundary_test.go vẫn bảo vệ nó.

Kiểu dữ liệu

Mặc định, sqlc dùng các kiểu riêng của pgx cho mọi thứ có thể NULL, như pgtype.Timestamptz và pgtype.UUID. Chúng sẽ rò rỉ vào mọi nơi gọi. sqlc.yaml ghi đè chúng:

  • uuid trở thành uuid.UUID của thư viện chuẩn Go 1.27, hoặc *uuid.UUID khi cột có thể NULL.
  • timestamptz trở thành time.Time, hoặc *time.Time khi nó có thể NULL.
  • jsonb trở thành json.RawMessage.
  • users.role trở thành store.Role và tenants.kind trở thành store.TenantKind, để các tập đóng từ giai đoạn 2 giữ nguyên kiểu của chúng.

Ba phép rename (trace_llm_content thành TraceLLMContent, api_key_id thành APIKeyID và api_key thành APIKey) khớp với cách đặt tên của Go. Với các thiết lập đó, hầu hết row struct do sqlc sinh có đúng các field giống như các type của store, theo cùng thứ tự. Go cho phép chuyển đổi giữa hai kiểu struct có field giống hệt nhau, nên mapper chỉ là một biểu thức:

u, err := us.s.db().GetUser(ctx, GetUserParams{TenantID: us.s.scope.TenantID, ID: id})
return store.User(u), mapErr(err)

Nếu ai đó thêm một field vào store.User mà không thêm vào bảng, dòng đó sẽ không compile được nữa. Audit event là ngoại lệ duy nhất: bảng lưu một actor vắng mặt dưới dạng NULL, trong khi store.AuditEvent dùng uuid.Nil(), nên toStoreAudit trong audit.go map từng field một.

Quy tắc tenant giờ nằm ở đâu

Ở giai đoạn 2 quy tắc là: mọi statement trên một bảng thuộc về tenant đều bind tenant_id từ handle. Vẫn là quy tắc đó, giờ viết bằng SQL:

-- name: ListUsers :many
select * from users
 where tenant_id = @tenant_id
   and (sqlc.narg('only_user')::uuid is null or id = sqlc.narg('only_user'))
 order by created_at;

@tenant_id là một field bắt buộc của ListUsersParams. Code duy nhất điền nó là một method của sub-store trên scoped, lấy giá trị từ s.scope.TenantID. Không method nào nhận tenant làm đối số. Các test cross-tenant từ giai đoạn 2 chạy nguyên không đổi, và chúng là bằng chứng rằng việc chuyển đổi không mở ra một lỗ hổng.

ResolveAPIKey trong queries/auth.sql vẫn là query duy nhất không có tenant trên một bảng thuộc về tenant, vì việc tìm ra tenant chính là nhiệm vụ của nó. Chỉ store.AuthLookup gọi nó.

Filter tùy chọn mà không dựng chuỗi

Một member chỉ thấy user, key và dòng audit của chính mình. Giai đoạn 2 nối thêm and id = $2 vào chuỗi SQL khi người gọi là member. Nội dung query phụ thuộc vào người gọi.

sqlc cần nội dung cố định, nên filter luôn có mặt và một parameter tắt nó đi:

(sqlc.narg('only_user')::uuid is null or id = sqlc.narg('only_user'))

sqlc.narg nghĩa là “nullable argument” (đối số có thể null), nên field Go là *uuid.UUID. Khi nó là nil, Postgres thấy NULL is null, tức là đúng, và filter cho mọi dòng đi qua. Khi nó chứa một id, chỉ dòng đó khớp. scoped.ownUserID() trả về nil cho owner và admin và id của người gọi cho một member, đúng thứ mà parameter cần. Danh sách audit dùng cùng mẹo này cho since, action và target_id.

Phép ép kiểu ::uuid cho sqlc biết kiểu. Thiếu nó, sqlc không biết NULL is null thuộc kiểu gì và sinh ra interface{}.

Limit vẫn được tính trong Go, mặc định 100 và tối đa 500, rồi bind thành @row_limit. Một limit trong SQL cũng là một parameter như mọi parameter khác.

Các query tự bảo vệ mình

Hai query của giai đoạn 2 mang nhiều hơn một filter, và cả hai được chuyển nguyên như cũ:

  • CreateAPIKey insert bằng insert ... select ... from users where u.tenant_id = @tenant_id and u.id = @user_id. Nếu user không thuộc tenant, phần select không trả dòng nào, không có gì được insert, và method trả về store.ErrNotFound. Một foreign key một mình sẽ chấp nhận một user của tenant khác.
  • LockTenant giữ for no key update. Hai request của platform cùng kiểm tra “tenant này đã có owner chưa?” sẽ lấy lock trước, nên request thứ hai chờ request thứ nhất commit thay vì cả hai cùng tạo owner. Các insert vào bảng tham chiếu tới tenant không bị chặn.

Transaction

Hàm New(db DBTX) do sqlc sinh nhận bất cứ thứ gì có Exec, Query và QueryRow. Interface querier của hango từ giai đoạn 2 có ba method đó cộng thêm Begin, nên nó vừa vặn. InTx vẫn đổi một pool lấy một transaction, và New(s.q) chạy cùng code do sqlc sinh trên cả hai.

Cách xem server đang làm gì

sqlc đặt tên query ở đầu mỗi statement mà nó gửi đi:

-- name: ListUsers :many
select id, tenant_id, email, role, status, created_at from users ...

otelpgx chép statement vào attribute db.query.text của mỗi span database. Tên span vẫn là động từ SQL, như SELECT, vì otelpgx bỏ qua comment sqlc ở đầu khi đặt tên span. Trong Grafana, mở Explore, chọn Tempo, và chạy query TraceQL này:

{ span.db.query.text =~ ".*ListAuditEvents.*" }

Mọi lần gọi audit list trong khoảng thời gian đó đều hiện ra, kèm thời lượng, mà không phải đoán nội dung SQL.

Cách debug lớp này

sqlc generate thất bại

Đọc thông báo lỗi. Nó nêu tên file, dòng, và cột hoặc kiểu mà nó không phân giải được. Các nguyên nhân thường gặp: một cột không tồn tại trong các migration, một parameter mà sqlc không suy ra được kiểu (thêm phép ép kiểu ::type), hoặc một statement trong migration mà parser của sqlc không hiểu.

make sqlc-check thất bại

Ai đó đã đổi một query hoặc một migration mà không generate lại. Chạy make generate, xem diff, và commit nó cùng với thay đổi gây ra nó.

Một test thất bại sau khi sửa một query

Code do sqlc sinh đúng với SQL bạn đã viết, nên SQL là chỗ cần nhìn. Chạy query lỗi trong psql với các giá trị cụ thể. Với một filter tùy chọn, kiểm tra rằng phía Go gửi nil, không phải một pointer tới giá trị rỗng: &"" không phải NULL, và action = '' không khớp gì cả.

Code không compile sau một migration

Đó là sqlc đang làm đúng việc của nó. Một cột đổi kiểu hoặc đổi tên, struct do sqlc sinh đã đổi, và compiler đang chỉ cho bạn mọi chỗ từng dùng nó.

Testing

Không có gì mới. Các test của giai đoạn 2 trong internal/store/pg, internal/server và internal/app đều qua mà không cần sửa, đó là bằng chứng rằng hành vi không đổi. Một test đã đổi đối tượng kiểm tra: TestAuditStoreNeverUpdatesOrDeletes từng tìm update hoặc delete trong audit.go. SQL giờ nằm trong queries/, và một lệnh purge có thể được thêm vào bất kỳ file query nào, nên test đọc mọi statement trong mọi file query (bỏ qua comment) và thất bại với statement nào sửa hoặc xóa các dòng audit_events. Nó vẫn kiểm tra audit.go phòng khi ai đó thêm một query viết tay ở đó.

Những giới hạn gặp trong thực tế

  • Mỗi query một row struct. GetAPIKey, ListAPIKeys, CreateAPIKey và RevokeAPIKey chọn cùng các cột nhưng mỗi cái có một kiểu ...Row riêng, vì sqlc đặt tên kiểu theo query. Chúng đều chuyển đổi được sang store.APIKey, nên ở đây không tốn gì, nhưng nó là nhiễu trong api_keys.sql.go.
  • select * trong một query được khai triển lúc generate. Thêm một cột vào users làm GetUser đổi theo ở lần generate lại tiếp theo. Thường đó là điều bạn muốn, và đó là lý do các query của key liệt kê rõ các cột: chúng không bao giờ được trả về key_hash.
  • sqlc.narg không phân biệt được “chưa đặt” với “đặt thành NULL”. Với một filter thì không sao. Với một partial update (“đổi tên, để nguyên email”) thì không ổn, vì bạn không thể nói “đặt email thành NULL”. Giai đoạn 4 sẽ gặp chuyện này; cách thường dùng là một parameter boolean riêng cho mỗi field, hoặc một query cho mỗi dạng update.
  • sqlc gắn comment phía trên một query vào Go doc của method do sqlc sinh, nên một comment ở đầu file query sẽ nằm trên method đầu tiên. Vì vậy các file query của hango không có comment cấp file; mỗi ghi chú nằm trên query mà nó mô tả.
  • Không dùng được SQL động. Một cột sắp xếp chọn lúc chạy, hay một danh sách filter có độ dài không biết trước, không thể là chữ cố định. Đó là việc của escape hatch.

Escape hatch

Nếu một query thật sự không viết được cho sqlc, hãy viết tay bằng pgx trong cùng package, scan vào struct do sqlc sinh (các tag db giúp pgx.RowToStructByName chạy được), và thêm một comment nêu giới hạn. Giai đoạn 2.1 không cần cái nào.

Đánh đổi

  • sqlc làm mặc định so với trộn lẫn. Chia query giữa sqlc và code viết tay theo sở thích nghĩa là mọi người đọc phải biết hai phong cách. Một mặc định kèm một ngoại lệ có tên giữ quy tắc đơn giản.
  • Code do sqlc sinh trong pg so với package riêng. Một package riêng sẽ gọn hơn và tốn một trong hai mươi package, và bất cứ thứ gì trong đó có thể bị import bởi nhiều code hơn mức quy tắc tenant cho phép.
  • Type override so với pgtype. Override làm các struct do sqlc sinh trông như viết tay. Cái giá là một sqlc.yaml dài hơn và một pointer cho mỗi cột nullable.
  • go run với phiên bản được ghim so với một dòng tool trong go.mod. Một dependency tool sẽ kéo cây dependency lớn của sqlc vào module graph. go run pkg@version giữ nó ở ngoài, với giá là lần chạy đầu chậm.
  • Commit code do sqlc sinh so với generate trong CI. Code đã commit nghĩa là build và test hango không bao giờ cần sqlc hay cgo. make sqlc-check giữ cho nó trung thực, và job CI của giai đoạn 16 sẽ chạy nó.

goclaw và deepagents làm việc này thế nào

goclaw (~/ws/personal/goclaw) dùng database/sql với driver thư viện chuẩn của pgx, sqlx để scan vào struct, golang-migrate cho migration, và SQL viết tay. Lý do là SQLite: goclaw cũng có một SQLite store (internal/store/sqlitestore), và database/sql là điểm chung. sqlc cũng sinh được cho SQLite, nhưng mỗi engine cần query và đầu ra riêng. hango chỉ dùng Postgres, nên nó dùng được pgx gốc và sinh một bộ code duy nhất.

langchain deepagents không có lớp SQL riêng. Nó lưu trạng thái qua các checkpointer và store của LangGraph, vốn giấu database sau một interface key-value.

Bài tập

  1. Thêm một cột. Viết một migration thêm display_name text not null default '' vào users, chạy make generate, rồi go build ./.... Lần theo các lỗi: dòng nào hỏng đầu tiên, và vì sao? (Gợi ý: store.User(u).)
  2. Cố ý phá quy tắc tenant. Trong queries/api_keys.sql, đổi where tenant_id = @tenant_id and id = @id của GetAPIKey thành where @tenant_id::uuid is not null and id = @id, giữ lại parameter nhưng không còn lọc theo nó. Chạy make generate và go test ./internal/store/pg/ -run TestCrossTenant -v, rồi xem case api_keys thất bại. Sau đó thử xóa hẳn tenant_id = @tenant_id and: sqlc bỏ parameter TenantID, GetAPIKeyParams biến mất, và go build thất bại trước khi bất kỳ test nào chạy. Khôi phục query và generate lại.
  3. Thử một sắp xếp lúc chạy. Sửa ListAuditEvents để người gọi chọn được occurred_at hoặc action làm cột sắp xếp. Thử bằng một parameter (order by @sort_col) và đọc xem Postgres làm gì. Rồi viết nó như một escape hatch trong audit.go, scan vào AuditEvent.
  4. Tìm một query trong Tempo. Khởi động stack bằng make obs-up, chạy hango trên máy host với HANGO_DATABASE_URL trỏ tới Postgres của compose (HOST_DATABASE_URL trong Makefile) và một HANGO_BOOTSTRAP_ADMIN_KEY, tạo một tenant, một owner và một key như trong phần hướng dẫn ở Persistence và tenancy, gọi GET /v1/audit-events với key đó vài lần, rồi tìm các span ListAuditEvents bằng query TraceQL ở trên.