株式会社HRBrain
🌱

【Go】sqlxからsqlcへの移行をしてから半年たった

2024/12/18に公開

はじめに

こんにちは、こんばんわ、新卒一年目のなかじです〜💪

sqlxからsqlcに移行し半月ほど経ったので、記事にしようと思います!

(PostgreSQLを使用しており、ドライバはpgx/v5を使っています!)

対象読者

  • sqlcに興味がある方
  • これからsqlc を導入してみようと考えている方

sqlcについて

sqlcは、SQLファイルからデータベースにアクセスできる型安全なGoのコードを生成するライブラリです。

従来のGoのコードでは、SQLクエリの手動マッピングや構造体タグの記述、ランタイムで発生するエラーに悩みがあり、そこを楽するためにSQLのクエリをコンパイルして型安全なGoコードを自動生成してくれるのがsqlcです!

https://conroy.org/introducing-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

https://docs.sqlc.dev/en/stable/reference/config.html#gen

  • overrides の設定

    sqlcを使用してSQLクエリから生成されるコードで、データベース型に対応するGoの型をカスタマイズするための設定です

    - db_type: "uuid"
      go_type:
        import: "github.com/google/uuid"
        type: "UUID"
    

    PostgreSQLのuuid型に対応するGo型をgithub.com/google/uuid.UUIDに変更します。

https://docs.sqlc.dev/en/stable/reference/config.html#id1

  • sqlc vet の設定

https://docs.sqlc.dev/en/stable/howto/vet.html

makefileにsqlcを追加

sqlc: 
  -rm internal/repositories/sqlcgen/*.sql.go
  sqlc generate

sqlxからsqlcへの移行

基本的な書き方の紹介です!いい感じにsqlxsqlcの差分を読み取ってもらえたら嬉しいです!

今回は、chatGPTに出してもらった以下のテーブルでCreateとRead、Updateをしていこうと思います!

schema.sql
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を行うと、以下が生成されます!

models.go
type Place struct {
	ID          int32
	Tags        []string
	Name        string
	Address     *string
	CreatedAt   time.Time
	PublishedAt *time.Time
	UpdatedAt   time.Time
}
genされたコード
queries/places.sql
-- 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

repositories/place.go
// 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されたコード
sqlcgen/places.sql.go
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行返す)

repositories/place.go
// 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されたコード
sqlcgen/places.sql.go
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(複数行返す)

repositories/place.go
// 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されたコード
sqlcgen/places.sql.go
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の選定については、こちらの記事が詳しく書いてある気がします!(参考にさせていただいたところもある🙇)

https://zenn.dev/yuyu_hf/articles/6e5af8fb0af0e4

https://future-architect.github.io/articles/20221128a/

Qiitaでも、同じ記事あげてます!

https://qiita.com/nakampany/items/0df035b365f6770a4d5b

PR

株式会社HRBrainでは、一緒に働く仲間を募集しています!
興味を持っていただけた方はぜひ弊社の採用ページをご確認ください!

https://www.hrbrain.co.jp/recruit

株式会社HRBrain
株式会社HRBrain

Discussion