フューチャー技術ブログ

SQLファイルから型安全なコードを生成するsqlc

TIGの辻です。GoのORマッパー連載8日目です。本記事では sqlc を紹介します。早速ですが、結論から行きましょう。

sqlc まとめ

  • SQLファイルからデータベースにアクセスできる型安全なGoのコードを生成するライブラリ
    • 構造体のモデルの手書き実装不要
    • 複数テーブルをJOINしたときのマッパー実装不要
    • 生成されるコードは不要なリフレクションなし

SQLをがんがん書きたい、でも面倒なマッパー構造体は書きたくない、という開発者にとっては大きな味方になります。

sqlc の紹介

sqlc はSQLファイルからGoのアプリケーションコードを生成するライブラリです。2020/2に v1.0.0 をリリースし、着々とスターを伸ばしています。2021/08現在は v1.8.0 をリリースしています。本資料で生成しているコードも v1.8.0 を用いています。

https://star-history.t9t.io/#kyleconroy/sqlc

2021/08現在ではMySQLとPostgreSQLの2つのデータベースをサポートしています。

データベースのパーサを適用してクエリを解析している点が設計上の大きな特徴です。解析エンジンがPostgreSQLの場合、実際のPostgreSQLサーバーのソースを cgo を経由して、Goから呼び出せるようになっています。PostgreSQLのクエリ解析エンジン本体は pganalyze/pg_query_go が提供しています。

ひとたび以下のようなSQLを実装すれば、sqlc generate コマンドを実行することで、型安全なGoのアプリケーションコードが生成できます。SQLファイルは複数に分割することもできます。ユースケースごとにSQLファイルを分ける、といった使い方ができるでしょう。

-- name: GetAuthor :one
SELECT * FROM author
WHERE id = $1 LIMIT 1;

-- name: ListAuthors :many
SELECT * FROM author
ORDER BY id;

-- name: CreateAuthor :one
INSERT INTO author (id, name) VALUES ($1, $2) RETURNING *;

-- name: DeleteAuthor :exec
DELETE FROM author
WHERE id = $1;

-- name: ListBookOverPrice :many
SELECT
b.title
, a.name
, b.price
FROM
book b
LEFT JOIN
author a
ON 1 = 1
AND b.author_id = a.id
WHERE
price > $1
ORDER BY
b.title
;

※データベースのスキーマ例

本記事ではデータベースはPostgreSQLとします。

create table author
(
id integer PRIMARY KEY,
name varchar(99) not null,
created_at timestamp not null default now()
);

create table book
(
id integer PRIMARY KEY,
title varchar(99) not null,
price integer not null,
author_id integer not null,
created_at timestamp not null default now()
);

alter table book add foreign key (author_id) references author (id);

sqlc の作者が書いている記事 Introducing sqlc - Compile SQL queries to type-safe Go の中にある How to use sqlc in 3 steps という謳い文句に嘘はないです。とてもシンプル。

  • SQLのクエリを書く
  • sqlc コマンドを実行して、クエリに対する型安全性の高いインタフェースを提供するGoのコードを生成する
  • sqlc で生成したメソッドを呼び出すアプリケーションコードを書く

実際に上のSQLファイルに対して sqlc generate コマンドを実行すると以下のようなGoのコードが生成されます。

生成されたSQLファイル

  • db.go
  • models.go
  • query.sql.go
db.go
// Code generated by sqlc. DO NOT EDIT.

package db

import (
"context"
"database/sql"
)

type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}

func New(db DBTX) *Queries {
return &Queries{db: db}
}

type Queries struct {
db DBTX
}

func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
models.go
// Code generated by sqlc. DO NOT EDIT.

package db

import (
"time"
)

type Author struct {
ID int32
Name string
CreatedAt time.Time
}

type Book struct {
ID int32
Title string
Price int32
AuthorID int32
CreatedAt time.Time
}
query.sql.go
// Code generated by sqlc. DO NOT EDIT.
// source: query.sql

package db

import (
"context"
)

const createAuthor = `-- name: CreateAuthor :one
INSERT INTO author (id, name) VALUES ($1, $2) RETURNING id, name, created_at
`

type CreateAuthorParams struct {
ID int32
Name string
}

func (q *Queries) CreateAuthor(ctx context.Context, arg CreateAuthorParams) (Author, error) {
row := q.db.QueryRowContext(ctx, createAuthor, arg.ID, arg.Name)
var i Author
err := row.Scan(&i.ID, &i.Name, &i.CreatedAt)
return i, err
}

const deleteAuthor = `-- name: DeleteAuthor :exec
DELETE FROM author
WHERE id = $1
`

func (q *Queries) DeleteAuthor(ctx context.Context, id int32) error {
_, err := q.db.ExecContext(ctx, deleteAuthor, id)
return err
}

const getAuthor = `-- name: GetAuthor :one
SELECT id, name, created_at FROM author
WHERE id = $1 LIMIT 1
`

func (q *Queries) GetAuthor(ctx context.Context, id int32) (Author, error) {
row := q.db.QueryRowContext(ctx, getAuthor, id)
var i Author
err := row.Scan(&i.ID, &i.Name, &i.CreatedAt)
return i, err
}

const listAuthors = `-- name: ListAuthors :many
SELECT id, name, created_at FROM author
ORDER BY id
`

func (q *Queries) ListAuthors(ctx context.Context) ([]Author, error) {
rows, err := q.db.QueryContext(ctx, listAuthors)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Author
for rows.Next() {
var i Author
if err := rows.Scan(&i.ID, &i.Name, &i.CreatedAt); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

const listBookOverPrice = `-- name: ListBookOverPrice :many
SELECT
b.title
, a.name
, b.price
FROM
book b
LEFT JOIN
author a
ON 1 = 1
AND b.author_id = a.id
WHERE
price > $1
ORDER BY
b.title
`

type ListBookOverPriceRow struct {
Title string
Name string
Price int32
}

func (q *Queries) ListBookOverPrice(ctx context.Context, price int32) ([]ListBookOverPriceRow, error) {
rows, err := q.db.QueryContext(ctx, listBookOverPrice, price)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListBookOverPriceRow
for rows.Next() {
var i ListBookOverPriceRow
if err := rows.Scan(&i.Title, &i.Name, &i.Price); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

上記では db パッケージとして生成されました。パッケージ名は sqlc設定ファイルで調整できます。

アプリケーション実装例

sqlc が生成したコードを使うアプリケーションの実装例は以下のような感じです。

main.go
package main

import (
"context"
"database/sql"
"fmt"

"github.com/d-tsuji/go-sandbox/sqlc/db"
_ "github.com/jackc/pgx/v4/stdlib"
)

func main() {
pgx, err := sql.Open("pgx", "postgres://booktest:pass@localhost:15432/testdb?sslmode=disable")
if err != nil {
panic(err)
}
ctx := context.Background()
q := db.New(pgx)

// -----------------------------------------------------------
// create user
param := db.CreateAuthorParams{
ID: 104,
Name: "Daishiro Tsuji",
}
u, err := q.CreateAuthor(ctx, param)
if err != nil {
panic(err)
}
fmt.Println(u)
// {104 Daishiro Tsuji 2021-08-02 08:53:51.40108 +0000 UTC}

// get user
u, err = q.GetAuthor(ctx, 101)
if err != nil {
panic(err)
}
fmt.Println(u)
// {101 Mat Ryer 2021-08-02 08:53:44.580572 +0000 UTC}

// delete user
if err := q.DeleteAuthor(ctx, 104); err != nil {
panic(err)
}

// list user
ls, err := q.ListBookOverPrice(ctx, 3500)
if err != nil {
panic(err)
}
for _, l := range ls {
fmt.Println(l)
}
// {Go言語でつくるインタプリタ Thorsten Ball 3740}
// {Go言語によるWebアプリケーション開発 Mat Ryer 3520}
}

個人的に特に嬉しいポイント

  • クエリベースでコード生成可能

データベースに対して発行するSQLのSELECT文は、経験上、複数のテーブルをJOINすることが多く、また、複雑になりがちです。またデータベースクライアントでデータベースに接続し、実際にクエリを発行し、実行計画を確認しながらクエリの性能をチェックすることが多いです。

SQLを書いてしまうことが多く、記述したSQLをもとに型安全なGoのアプリケーションコードを生成できるのはかなり嬉しいポイントです。

  • 自作のマッパー構造体不要

また、他のO/Rマッパを使った場合、モデルのコードがテーブルベースであることが多く、生のSQLをO/Rマッパに実装したとしても、結果を取得するマッパーのモデルはクエリ個別に作ることが必要になることもあります。こうしたSELECT文におけるマッパーが不要な点も sqlc を使う嬉しいポイントと言えます。

  • O/Rマッパライブラリ不要

生成されたコードを用いることで直接クエリの結果を取得できます。すなわち、database/sql パッケージを直接用いることでO/Rマッパライブラリは不要となります。

参考