【Go】sqlxからsqlcへの移行をしてから半年たった
はじめに
こんにちは、こんばんわ、新卒一年目のなかじです〜💪
sqlxからsqlcに移行し半月ほど経ったので、記事にしようと思います!
(PostgreSQLを使用しており、ドライバはpgx/v5を使っています!)
対象読者
- sqlcに興味がある方
- これからsqlc を導入してみようと考えている方
sqlcについて
sqlc
は、SQLファイルからデータベースにアクセスできる型安全なGoのコードを生成するライブラリです。
従来のGoのコードでは、SQLクエリの手動マッピングや構造体タグの記述、ランタイムで発生するエラーに悩みがあり、そこを楽するためにSQLのクエリをコンパイルして型安全なGoコードを自動生成してくれるのがsqlc
です!
sqlcを導入するときに用意したもの
sqlc.yaml
の用意
自分が用意したyamlファイルです!
こちらを参考にしました!
version: "2"
sql:
- engine: "postgresql"
schema: "internal/repositories/db/schema.sql"
queries: "internal/repositories/db/queries"
database:
uri: postgresql://postgres:${PG_PASSWORD}@localhost:5432/authors
gen:
go:
package: "sqlcgen"
sql_package: "pgx/v5"
out: "internal/repositories/sqlcgen"
emit_pointers_for_null_types: true
overrides:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
nullable: true
- db_type: "pg_catalog.timestamptz"
go_type:
import: "time"
type: "Time"
- db_type: "pg_catalog.timestamptz"
go_type:
import: "time"
type: "Time"
pointer: true
nullable: true
-
emit_pointers_for_null_types
の設定
sql.NullStringのwrap構造体でValidフィールドをチェックする必要がなくなり、nilかどうかを判定するだけでよくなるので、trueに設定しましたsql.NullString
→*string
sql.NullInt64
→*int64
-
overrides
の設定sqlcを使用してSQLクエリから生成されるコードで、データベース型に対応するGoの型をカスタマイズするための設定です
- db_type: "uuid" go_type: import: "github.com/google/uuid" type: "UUID"
PostgreSQLのuuid型に対応するGo型を
github.com/google/uuid.UUID
に変更します。
-
sqlc vet
の設定
sqlc
を追加
makefileにsqlc:
-rm internal/repositories/sqlcgen/*.sql.go
sqlc generate
sqlxからsqlcへの移行
基本的な書き方の紹介です!いい感じにsqlx
とsqlc
の差分を読み取ってもらえたら嬉しいです!
今回は、chatGPTに出してもらった以下のテーブルでCreateとRead、Updateをしていこうと思います!
CREATE TABLE places (
id SERIAL PRIMARY KEY,
tags TEXT[],
name TEXT NOT NULL,
address TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
published_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
この段階で、make sqlc
を行うと、以下が生成されます!
type Place struct {
ID int32
Tags []string
Name string
Address *string
CreatedAt time.Time
PublishedAt *time.Time
UpdatedAt time.Time
}
genされたコード
-- name: UpdatePlace :execrows
INSERT INTO places (tags, name, address, created_at, published_at, updated_at)
VALUES (
$1,
$2,
$3,
NOW(),
$4,
NOW()
)
ON CONFLICT (id)
DO UPDATE SET
tags = EXCLUDED.tags,
name = EXCLUDED.name,
address = EXCLUDED.address,
published_at = EXCLUDED.published_at,
updated_at = NOW();
-- name: GetPlace :one
SELECT * FROM places WHERE id = $1;
-- name: FindPublishedPlaces :many
SELECT * FROM places WHERE published_at < NOW();
Create&Update
// sqlxのコード
func (r placeRepository) UpdatePlace(ctx context.Context, place domain.Place) error {
query := `
INSERT INTO places (tags, name, address, created_at, published_at, updated_at)
VALUES ($1, $2, $3, NOW(), $4, NOW())
ON CONFLICT (id)
DO UPDATE SET tags = EXCLUDED.tags, name = EXCLUDED.name, address = EXCLUDED.address, published_at = EXCLUDED.published_at, updated_at = NOW()
`
result, err := r.db.GetConn(ctx).ExecContext(
ctx,
query,
place.Tags,
place.Name,
place.Address,
place.PublishedAt,
)
if err != nil {
return fmt.Errorf(": %w", err)
}
count, err := result.RowsAffected()
if err != nil {
return fmt.Errorf(": %w", err)
}
if count == 0 {
return fmt.Errorf("no rows affected")
}
return nil
}
// sqlcのコード
func (r placeRepository) UpdatePlace(ctx context.Context, place domain.Place) error {
queries := r.db.Queries(ctx)
rowsAffected, err := queries.UpdatePlace(ctx, sqlcgen.UpdatePlaceParams{
Tags: place.Tags,
Name: place.Name,
Address: place.Address,
PublishedAt: place.PublishedAt,
})
if err != nil {
return fmt.Errorf(": %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf(": %w", err)
}
return nil
}
genされたコード
const updatePlace = `-- name: UpdatePlace :execrows
INSERT INTO places (tags, name, address, created_at, published_at, updated_at)
VALUES (
$1,
$2,
$3,
NOW(),
$4,
NOW()
)
ON CONFLICT (id)
DO UPDATE SET
tags = EXCLUDED.tags,
name = EXCLUDED.name,
address = EXCLUDED.address,
published_at = EXCLUDED.published_at,
updated_at = NOW()
`
type UpdatePlaceParams struct {
Tags []string
Name string
Address *string
PublishedAt *time.Time
}
func (q *Queries) UpdatePlace(ctx context.Context, arg UpdatePlaceParams) (int64, error) {
result, err := q.db.Exec(ctx, updatePlace,
arg.Tags,
arg.Name,
arg.Address,
arg.PublishedAt,
)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
Read(1行返す)
// sqlxのコード
type place struct {
ID uint64 `db:"id"`
Tags string `db:"tags"`
Name string `db:"name"`
Address string `db:"address"`
PublishedAt string `db:"published_at"`
}
func (r placeRepository) GetPlace(ctx context.Context, id uint64) (domain.Place, error) {
query := `SELECT * FROM places WHERE id = $1`
var place place
err := r.db.GetConn(ctx).GetContext(
ctx,
&place,
query,
id,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.Place{}, nil
}
return domain.Place{}, fmt.Errorf(": %w", err)
}
return NewPlace(place), nil
}
// sqlcのコード
func (r placeRepository) GetPlace(ctx context.Context, placeID domain.PlaceID) (domain.Place, error) {
queries := r.db.Queries(ctx)
place, err := queries.GetPlace(ctx, sqlcgen.GetPlaceParams{
ID: placeID,
})
if err != nil {
return domain.Place{}, fmt.Errorf(": %w", err)
}
return NewPlace(place), nil
}
genされたコード
const getPlace = `-- name: GetPlace :one
SELECT id, tags, name, address, created_at, published_at, updated_at FROM places WHERE id = $1
`
func (q *Queries) GetPlace(ctx context.Context, id int32) (Place, error) {
row := q.db.QueryRow(ctx, getPlace, id)
var i Place
err := row.Scan(
&i.ID,
&i.Tags,
&i.Name,
&i.Address,
&i.CreatedAt,
&i.PublishedAt,
&i.UpdatedAt,
)
return i, err
}
Read(複数行返す)
// sqlxのコード
type place struct {
ID uint64 `db:"id"`
Tags string `db:"tags"`
Name string `db:"name"`
Address string `db:"address"`
PublishedAt string `db:"published_at"`
}
func (r placeRepository) FindPublishedPlaces(ctx context.Context) ([]domain.Place, error) {
query := `SELECT * FROM places WHERE published_at < NOW()
`
var places []place
err := r.db.GetConn(ctx).SelectContext(
ctx,
&places,
query,
)
if err != nil {
return nil, fmt.Errorf(": %w", err)
}
return NewPlaces(places), nil
}
// sqlcのコード
func (r placeRepository) FindPublishedPlaces(ctx context.Context) ([]domain.Place, error) {
queries := r.db.Queries(ctx)
places, err := queries.FindPublishedPlaces(ctx)
if err != nil {
return nil, fmt.Errorf(": %w", err)
}
return NewPlaces(places), nil
}
genされたコード
const findPublishedPlaces = `-- name: FindPublishedPlaces :many
SELECT id, tags, name, address, created_at, published_at, updated_at FROM places WHERE published_at < NOW()
`
func (q *Queries) FindPublishedPlaces(ctx context.Context) ([]Place, error) {
rows, err := q.db.Query(ctx, findPublishedPlaces)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Place
for rows.Next() {
var i Place
if err := rows.Scan(
&i.ID,
&i.Tags,
&i.Name,
&i.Address,
&i.CreatedAt,
&i.PublishedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
移行してよかったこと
SQL中心の開発スタイルによる効率化
アプリケーションコードにSQLクエリを直接記述するsqlx
からSQLファイルに記述したSQLクエリを元にアプリケーションコードを生成するので、SQLクエリに集中できるので切り分けができ、結果的に実装やレビュー面でも開発スピードが上がった気がします!
クエリミスの早期発見
sqlc generate
でSQLファイルに記述したSQLクエリの解析が行われるため、生成時点でSQLクエリの軽微なミス(typo)や構文エラーを事前に検出できることは良いなと思いました!
コードの簡潔さ
生成されたコードを、repository層で呼ぶことになると思いますが、CRUDでほぼ似たようなコードを書いたら良いので、その点も良かったなと思います!
難しいなと思ったこと
動的かつ複雑なクエリの扱いにくさ
動的なクエリや条件が多い複雑なロジックを記述する際に、柔軟性がやや制限され、書きづらさを感じる場面があり、少し難しいなーと思ったりしました。
ほんとに、複雑なクエリを書くときは、sqlcのgenを使わずに、アプリケーションコードにSQLを書かいたほうが良い時はありました〜
-
placesテーブルを検索する機能について
type PlaceFilter struct { Tags []string Name string PublishedAt *time.Time } func (f PlaceFilter) NumParams() int { count := 0 if len(f.Tags) > 0 { count++ } if f.Name != "" { count++ } if f.PublishedAt != nil { count++ } return count } func (r *PlaceRepository) Find( ctx context.Context, filter domain.PlaceFilter, ) ([]domain.Place, error) { args := make([]any, 0, filter.NumParams()) filters := make([]string, 0, filter.NumParams()) if len(filter.Tags) > 0 { args = append(args, filter.Tags) filters = append(filters, fmt.Sprintf("tags && $%d", len(args))) } if filter.Name != "" { args = append(args, "%"+filter.Name+"%") filters = append(filters, fmt.Sprintf("name LIKE $%d", len(args))) } if filter.PublishedAt != nil { args = append(args, *filter.PublishedAt) filters = append(filters, fmt.Sprintf("published_at = $%d", len(args))) } sql := fmt.Sprintf( `SELECT id, tags, name, address, created_at, published_at, updated_at FROM places WHERE %s;`, strings.Join(filters, " AND "), ) rows, err := r.db.Query(ctx, sql, args...) if err != nil { return nil, fmt.Errorf("y: %w", err) } defer rows.Close() places := []Place{} for rows.Next() { var place Place err := rows.Scan( &place.ID, &place.Tags, &place.Name, &place.Address, &place.CreatedAt, &place.PublishedAt, &place.UpdatedAt, ) if err != nil { return nil, fmt.Errorf(": %w", err) } places = append(places, place) } if err := rows.Err(); err != nil { return nil, fmt.Errorf(": %w", err) } return places, nil }
SQLの書き方もフォーマットが人によって違う
ツールを使えばいいじゃんと思いますが、個人的意見ですが、言語と違いSQLは人それぞれのフォーマットの見やすさの好みがあると思っています!
そこを揃えるのは、少し苦労した印象です!💦
おわりに
個人的には、sqlcは開発体験がよくて好きです!
今後もsqlcを使った開発を積極的にしてきたいなと!
sqlcの良い点&難しいかった点すごく以下の記事の内容に共感した、かつsqlcの選定については、こちらの記事が詳しく書いてある気がします!(参考にさせていただいたところもある🙇)
Qiitaでも、同じ記事あげてます!
PR
株式会社HRBrainでは、一緒に働く仲間を募集しています!
興味を持っていただけた方はぜひ弊社の採用ページをご確認ください!
Discussion