8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HRBrainAdvent Calendar 2024

Day 16

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

Last updated at Posted at 2024-12-15

はじめに

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

アドベントカレンダー16日目の記事です!

今回は技術の記事です!

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

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

対象読者

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

sqlcについて

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

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

sqlcを導入するときに用意したもの

sqlc.yamlの用意

自分が用意したyamlファイルです!

こちらを参考にしました!

sqlc.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 の設定

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
}
今回書いたクエリ
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の選定については、こちらの記事が詳しく書いてある気がします!(参考にさせていただいたところもある🙇)

PR

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

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?