sqlc generate は何をしているのかメモ


[2023-09-11 追記] 以下のブログの方が分かりやすいので、そちらを読むことをお勧めします。


最近、個人開発で Go のアプリケーションからデータベースを操作するために sqlc を使っている。sqlc は SQL で書いたスキーマのファイルから sqlc generate コマンド一つで Go の構造体を、クエリのファイルから型安全な Go の SQL クライアントを生成することができる。1

私は普段の仕事で ORM が提供するメソッドやクエリビルダを使って SQL を組み立てている。ORM は便利ではあるが、最終的に実行される SQL は可読性が低くてデバッグしづらいことが多い。そのため「最初から SQL を書けば良いのでは…?」と考えるようになり、初めて sqlc を見たとき自分の好みに合うツールと確信した。

使い方は簡単で便利なのだが、どうやって sqlc generate コマンドで Go のコードを生成しているのか想像がつかなかったのでコードを読んでみることにした。対象バージョンは v1.20.0 とする。

sqlc の使い方

まずは sqlc の使い方をおさらいする。

  1. スキーマとクエリを SQL で書く
  2. sqlc generate コマンドで Go のメソッドと構造体を生成する
  3. 生成したメソッドと構造体を自分のアプリケーションから呼び出す

以下のようなディレクトリ構成で簡単なアプリケーションを作る。

.
├── database
│   ├── queries
│   │   └── queries.sql
│   └── schema
│       └── schema.sql
├── generated
│   ├── db.go
│   ├── models.go
│   └── queries.sql.go
├── main.go
└── sqlc.yaml

以下では例として PostgreSQL を使用するが、sqlc は MySQL でも SQLite でも使える。

まずは SQL でスキーマを書く。

CREATE TABLE IF NOT EXISTS "authors" (
  "id"   BIGSERIAL PRIMARY KEY,
  "name" text      NOT NULL,
  "bio"  text
);

次に SQL でデータベースを操作するクエリを書く。このとき、メソッド名と適用するコマンドをコメントで書いておく。使えるコマンドは公式ドキュメントに記載されている。

https://docs.sqlc.dev/en/stable/reference/query-annotations.html

:one:many 等のコマンドによってクエリを実行するメソッドの返り値の型を定義することができる。:batchMany などで PostgreSQL のドライバー pgx を使ってバッチ処理をすることもできる。業務システムで大量のデータをバッチ処理する際には有用そうだ。

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

-- name: ListAuthors :many
SELECT * FROM authors
ORDER BY name;

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

-- name: UpdateAuthor :one
UPDATE authors
  set name = $2,
  bio = $3
WHERE id = $1
RETURNING *;

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

sqlc では SQL や生成する Go ファイルの配置先を設定するファイルを YAML や JSON で書く。

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

例えば “./database/” 以下の SQL ファイルでできたスキーマとクエリを “tutorial” というパッケージ名で “./generated/” 以下に出力する設定は YAML で以下のように書くことができる。

version: "2"
sql:
  - engine: "postgresql"
    queries: "./database/queries/queries.sql"
    schema: "/database/schema/schema.sql"
    gen:
      go:
        package: "tutorial"
        out: "./generated"

ここまでの準備ができたら sqlc コマンドを sqlc の設定ファイルがあるディレクトリで実行する。

$ sqlc generate

これによりデータベースのクライアントとなる Go のメソッドと構造体を生成できる。ここからは実際に何が生成されたのかを見ていく。

まず db.go として、データベースへの接続やトランザクション管理などデータベース操作に関連したメソッドが自動生成されている。

// Code generated by sqlc. DO NOT EDIT.
// versions:
//   sqlc v1.20.0

package tutorial

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,
	}
}

ここで DBTX インターフェイスが定義する ExecContext などのメソッドは Go の database/sql パッケージ内の複数の型で実装されている。

次に、models.go としてデータベースのスキーマに沿った構造体が生成されている。

// Code generated by sqlc. DO NOT EDIT.
// versions:
//   sqlc v1.20.0

package tutorial

import (
	"database/sql"
)

type Author struct {
	ID   int64
	Name string
	Bio  sql.NullString
}

これは sqlc で自動生成したデータの取得・操作メソッドが返す値の型として自分のアプリケーションで使うことができる。

ここで生成されるファイルは設定ファイルやプラグインでカスタマイズできる。例えば先述の config ファイルで emit_json_tags を true にすると、以下のように構造体へ json タグを付与することができる。

type Author struct {
	ID   int64          `json:"id"`
	Name string         `json:"name"`
	Bio  sql.NullString `json:"bio"`
}

このタグはコード上ではただのコメントに過ぎないが、リフレクションを活用するパッケージを介して構造体のメタデータとして使うことができる。例えば encoding/json では JSON のフィールド名を構造体のフィールドにマッピングすることができる。これにより、外部から受け取った JSON をスムーズに Go の構造体へ読み込むことが簡単になる。

最後に queries.sql.go を見てみる。ここにはデータを取得・操作するメソッドが生成されている。

// Code generated by sqlc. DO NOT EDIT.
// versions:
//   sqlc v1.20.0
// source: queries.sql

package tutorial

import (
	"context"
	"database/sql"
)

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

type CreateAuthorParams struct {
	Name string
	Bio  sql.NullString
}

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

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

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

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

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

const listAuthors = `-- name: ListAuthors :many
SELECT id, name, bio FROM authors
ORDER BY name
`

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.Bio); 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 updateAuthor = `-- name: UpdateAuthor :one
UPDATE authors
  set name = $2,
  bio = $3
WHERE id = $1
RETURNING id, name, bio
`

type UpdateAuthorParams struct {
	ID   int64
	Name string
	Bio  sql.NullString
}

func (q *Queries) UpdateAuthor(ctx context.Context, arg UpdateAuthorParams) (Author, error) {
	row := q.db.QueryRowContext(ctx, updateAuthor, arg.ID, arg.Name, arg.Bio)
	var i Author
	err := row.Scan(&i.ID, &i.Name, &i.Bio)
	return i, err
}

生成されたコードをよく見ると 2 ~ 3 個のブロックが 1 セットで生成されていることがわかる。

  1. SQL のクエリを格納する定数
  2. (パラメータが複数ある場合)パラメータを示す型
  3. クエリを実行するためのメソッド(Context と 2. を引数に取る)

最初に書いた SQL は const で定数として定義されている。例えば authors テーブルに 1 レコードを追加する SQL を見ると…

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

SQL 部分は * がカラム名になっている以外は同じ。

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

type CreateAuthorParams struct {
	Name string
	Bio  sql.NullString
}

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

実際にこれを使うには、生成したメソッドと構造体を自分のアプリケーションから呼び出す。

	ctx := context.Background()

	db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
	if err != nil {
		return err
	}
	defer db.Close()

	client := tutorial.New(db)
	newAuthor, err := client.CreateAuthor(ctx, tutorial.CreateAuthorParams{
		Name: "new author",
		Bio: sql.NullString{
			String: "new author bio",
			Valid:  true,
		},
	})
	if err != nil {
		return err
	}

上記のように、

  1. SQL を書く
  2. 1 から Go の構造体とメソッドを自動生成する
  3. 2 を自分のアプリケーションから呼び出す

という 3 step で簡単にデータベースを操作するプログラムを書くことができてとても便利なパッケージであることが分かる。

sqlc generate は何をしているのか

ここから本題に入る。sqlc でコードを生成する generate コマンドが何をしているのか、愚直に最初から処理を辿っていく。

エントリーポイント

sqlc の main.go は次のようになっている。sqlc generate を実行すると cmd.Do の 1 つ目の引数として ["generate"] が渡される。

package main

import (
	"os"

	"github.com/sqlc-dev/sqlc/internal/cmd"
)

func main() {
	os.Exit(cmd.Do(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}

コマンドの実行に関する設定

internal/cmd の Do 関数 では、コマンドラインインターフェイスを作成するための外部パッケージとして Cobra を使っている。Go は標準パッケージ flag を使うことで CLI を作れるが、Cobra を使うとサブコマンド(例えば git addgit commit のような階層的なコマンド)を簡単に作れるというメリットがある。

Do 関数を見ていく(コメントは筆者が挿入した)。

func Do(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
  // ルートコマンドを初期化する
  // "git commit" における "git"
	rootCmd := &cobra.Command{Use: "sqlc", SilenceUsage: true}
  // 全てのコマンドで使用されるコマンドラインオプションを追加する
	rootCmd.PersistentFlags().StringP("file", "f", "", "specify an alternate config file (default: sqlc.yaml)")
  // ...省略...

  // サブコマンドを追加する
  // "git commit" における "commit"
	rootCmd.AddCommand(checkCmd)
  // ...省略...

  // ルートコマンドに対して引数, 入力, 出力, エラー出力を追加する
  // args, stdin, stdout, stderr は Do 関数の引数として受け取ったものをそのまま渡している
	rootCmd.SetArgs(args)
	rootCmd.SetIn(stdin)
	rootCmd.SetOut(stdout)
	rootCmd.SetErr(stderr)

	ctx := context.Background()
  // デバッグ用のトレースを初期化する
	if debug.Debug.Trace != "" {
		tracectx, cleanup, err := tracer.Start(ctx)
		if err != nil {
			fmt.Printf("failed to start trace: %v\n", err)
			return 1
		}
		ctx = tracectx
		defer cleanup()
	}
  // コマンドを実行する
	if err := rootCmd.ExecuteContext(ctx); err != nil {
		if exitError, ok := err.(*exec.ExitError); ok {
			return exitError.ExitCode()
		} else {
			return 1
		}
	}
	return 0
}

generate コマンドの実装

sqlc generate すると上記の ExecuteContext(ctx) の箇所で genCmd が呼ばれるので、次は genCmd がどのように定義されているかを見てみる。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/cmd.go#L189

var genCmd = &cobra.Command{
	Use:   "generate",
	Short: "Generate Go code from SQL",
	RunE: func(cmd *cobra.Command, args []string) error {
		defer trace.StartRegion(cmd.Context(), "generate").End()
		stderr := cmd.ErrOrStderr()
		dir, name := getConfigPath(stderr, cmd.Flag("file"))
		output, err := Generate(cmd.Context(), ParseEnv(cmd), dir, name, stderr)
		if err != nil {
			os.Exit(1)
		}
		defer trace.StartRegion(cmd.Context(), "writefiles").End()
		for filename, source := range output {
			os.MkdirAll(filepath.Dir(filename), 0755)
			if err := os.WriteFile(filename, []byte(source), 0644); err != nil {
				fmt.Fprintf(stderr, "%s: %s\n", filename, err)
				return err
			}
		}
		return nil
	},
}

8 行目の Generate 関数で出力を生成しているようなので、次は Generate 関数を見ていく。

Generate 関数の実装

Generate 関数は internal/cmd/generate.go で定義されている。長い関数なので、少しずつ分割しながら読む。

設定ファイルを読む

まずは readConfig で設定ファイルを読み込む。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L132

	configPath, conf, err := readConfig(stderr, dir, filename)
	if err != nil {
		return nil, err
	}

	base := filepath.Base(configPath)
	if err := config.Validate(conf); err != nil {
		fmt.Fprintf(stderr, "error validating %s: %s\n", base, err)
		return nil, err
	}

	if err := e.Validate(conf); err != nil {
		fmt.Fprintf(stderr, "error validating %s: %s\n", base, err)
		return nil, err
	}

readConfig では ParseConfig を呼ぶことで version ごとに別の関数を呼び出している。

v2 の場合は v2ParseConfig が呼ばれる。v2ParseConfig では io.Reader から入力として設定を読み込み、それを Config 型のインスタンスとして YAML をデコードして解析する。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/config/v_two.go#L11

リモート生成

コードのリモート生成が設定で有効にされている場合は remoteGenerate を呼び出す。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L148

	if conf.Cloud.Project != "" && !e.NoRemote {
		return remoteGenerate(ctx, configPath, conf, dir, stderr)
	}

remoteGenerate の実装は以下のようになっている。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L270

この関数はリモートのサービスにリクエストを送信して生成されたコードを受け取るためのクライアント側の処理で、日常的に使うことは少ないかもしれない。詳細は Discussions #2234 を参照。sqlc は将来的にこれで収益化するつもりなのかもしれない。

出力の準備

最終的に出力する output map[string]string{} とそれを作るための pairs []outPair を作る。pairs にはどのようなコード(例えば Go)を生成するか、どのプラグインを使って生成するか設定を追加していく。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L152-L176

	output := map[string]string{}
	errored := false

	var pairs []outPair
	for _, sql := range conf.SQL {
		if sql.Gen.Go != nil {
			pairs = append(pairs, outPair{
				SQL: sql,
				Gen: config.SQLGen{Go: sql.Gen.Go},
			})
		}
		if sql.Gen.JSON != nil {
			pairs = append(pairs, outPair{
				SQL: sql,
				Gen: config.SQLGen{JSON: sql.Gen.JSON},
			})
		}
		for i, _ := range sql.Codegen {
			pairs = append(pairs, outPair{
				SQL:    sql,
				Plugin: &sql.Codegen[i],
			})
		}
	}

例えば以下のような sqlc.yaml で generate する場合を考える。

version: "2"
sql:
  - engine: "postgresql"
    queries: "./database/queries/queries.sql"
    schema: "/database/schema/schema.sql"
    gen:
      go:
        package: "tutorial"
        out: "./generated"

sql.Gen.Go != nil なので pairs に下記の outPair が追加される。

outPair{
	SQL: sql,
	Gen: config.SQLGen{Go: sql.Gen.Go},
}

ここで追加した outPair は次の「並列処理の準備」のさらに次の「コードの生成」の箇所でイテレーションに使われる。

並列処理の準備

同時実行に関する Mutex を初期化する。出力の書き込み時に競合を避けるために使う。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L177-L182

	var m sync.Mutex
	grp, gctx := errgroup.WithContext(ctx)
	grp.SetLimit(runtime.GOMAXPROCS(0))

	stderrs := make([]bytes.Buffer, len(pairs))

コードの生成

pairs スライスに格納された出力ペアをイテレートし、それぞれのペアについて SQL の解析(parse)とコード生成(codegen)の処理を非同期的に並列実行していく。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L183-L255

// TODO: 以外のコメントは筆者が挿入した。

  // ↑ で作った pairs をイテレートしていく
	for i, pair := range pairs {
    // 現在の pair を格納する
		sql := pair
		errout := &stderrs[i]

    // 新しい goroutine を起動して非同期的に各ペアの処理を実行する
		grp.Go(func() error {
      // 全体の設定と現在の pair に入っている SQL オプションを結合する
			combo := config.Combine(*conf, sql.SQL)
      // プラグインの設定があれば追加する
			if sql.Plugin != nil {
				combo.Codegen = *sql.Plugin
			}

			// TODO: This feels like a hack that will bite us later
			joined := make([]string, 0, len(sql.Schema))
      // スキーマが書かれた SQL ファイルのパスを設定に追加する
			for _, s := range sql.Schema {
				joined = append(joined, filepath.Join(dir, s))
			}
			sql.Schema = joined

			joined = make([]string, 0, len(sql.Queries))
      // クエリが書かれた SQL ファイルのパスを設定に追加する
			for _, q := range sql.Queries {
				joined = append(joined, filepath.Join(dir, q))
			}
			sql.Queries = joined

			var name, lang string
			parseOpts := opts.Parser{
				Debug: debug.Debug,
			}

      // Go かプラグインかに応じて、名前を設定する
			switch {
			case sql.Gen.Go != nil:
				name = combo.Go.Package
				lang = "golang"

			case sql.Plugin != nil:
				lang = fmt.Sprintf("process:%s", sql.Plugin.Plugin)
				name = sql.Plugin.Plugin
			}

      // この pair の処理をモニタリングする
			packageRegion := trace.StartRegion(gctx, "package")
			trace.Logf(gctx, "", "name=%s dir=%s plugin=%s", name, dir, lang)

      // parse: SQL を解析する
			result, failed := parse(gctx, name, dir, sql.SQL, combo, parseOpts, errout)
			if failed {
				packageRegion.End()
				errored = true
				return nil
			}

      // codegen: コードを生成する
			out, resp, err := codegen(gctx, combo, sql, result)
			if err != nil {
				fmt.Fprintf(errout, "# package %s\n", name)
				fmt.Fprintf(errout, "error generating code: %s\n", err)
				errored = true
				packageRegion.End()
				return nil
			}

			files := map[string]string{}
      // 生成されたコードを files マップに書き込む
			for _, file := range resp.Files {
				files[file.Name] = string(file.Contents)
			}

      // グローバルな output マップに生成されたコードを追加する
      // 他の goroutine との競合を避けるために Mutex を使用する
			m.Lock()
			for n, source := range files {
				filename := filepath.Join(dir, out, n)
				output[filename] = source
			}
			m.Unlock()

			packageRegion.End()
			return nil
		})
	}

SQL の解析とコードの生成のロジックはそれぞれ parse 関数と codegen 関数に記述されているので、詳しく見ていく。

SQL の解析

parse 関数を見ていく。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L333-L362

コメントは筆者が挿入した。

func parse(ctx context.Context, name, dir string, sql config.SQL, combo config.CombinedSettings, parserOpts opts.Parser, stderr io.Writer) (*compiler.Result, bool) {
	defer trace.StartRegion(ctx, "parse").End()
  // SQL を解析するためのコンパイラを初期化する
  // sql.Engine によって使用する parser/catalog が変わる
	c := compiler.NewCompiler(sql, combo)
  // スキーマの SQL を解析する
	if err := c.ParseCatalog(sql.Schema); err != nil {
		fmt.Fprintf(stderr, "# package %s\n", name)
		if parserErr, ok := err.(*multierr.Error); ok {
			for _, fileErr := range parserErr.Errs() {
				printFileErr(stderr, dir, fileErr)
			}
		} else {
			fmt.Fprintf(stderr, "error parsing schema: %s\n", err)
		}
		return nil, true
	}
	if parserOpts.Debug.DumpCatalog {
		debug.Dump(c.Catalog())
	}
  // クエリの SQL を解析する
	if err := c.ParseQueries(sql.Queries, parserOpts); err != nil {
		fmt.Fprintf(stderr, "# package %s\n", name)
		if parserErr, ok := err.(*multierr.Error); ok {
			for _, fileErr := range parserErr.Errs() {
				printFileErr(stderr, dir, fileErr)
			}
		} else {
			fmt.Fprintf(stderr, "error parsing queries: %s\n", err)
		}
		return nil, true
	}
	return c.Result(), false
}
Parse について

クエリの形式はデータベースエンジンによって異なる。PostgreSQL 関連の処理は /internal/engine/postgresql 以下で internal の postgresql パッケージとして定義されている。

ParseCatalog や ParseQueries では /internal/engine/postgresql/parse.go で定義されている Parse 関数が呼ばれるので、それを読んでみる。

コメントは筆者が挿入した。

func (p *Parser) Parse(r io.Reader) ([]ast.Statement, error) {
  // ここで SQL のスキーマやクエリを受け取る
	contents, err := io.ReadAll(r)
	if err != nil {
		return nil, err
	}
  // SQL を解析して Parse tree を構築する
  // nodes.Parse による解析では github.com/pganalyze/pg_query_go/v4 を使っている
  // これにより解析結果の tree を得るので、後で最終的に Go のコードに変換するための AST に渡す
	tree, err := nodes.Parse(string(contents))
	if err != nil {
		pErr := normalizeErr(err)
		return nil, pErr
	}

	var stmts []ast.Statement
  // 解析した SQL の各ステートメントに対してイテレートする
	for _, raw := range tree.Stmts {
    // translate 関数で sqlc がコードを生成するための AST を構築する
		n, err := translate(raw.Stmt)
		if err == errSkip {
			continue
		}
		if err != nil {
			return nil, err
		}
		if n == nil {
			return nil, fmt.Errorf("unexpected nil node")
		}
		stmts = append(stmts, ast.Statement{
			Raw: &ast.RawStmt{
				Stmt:         n,
				StmtLocation: int(raw.StmtLocation),
				StmtLen:      int(raw.StmtLen),
			},
		})
	}
	return stmts, nil
}

translate 関数は非常に長いので読むのが大変だが、基本的には pganalyze/pg_query_go を使って解析された PostgreSQL のノードを sqlc 内の処理で使う AST に変換する責務を負っていると考えてよい。

sqlc 内の処理で使う AST は internal/sql/ast 以下に定義されている。

ParseCatalog について

Catalog はクエリを解析するために持つデータを集めた sqlc の内部的なデータ構造。PostgreSQL、MySQL、SQLite などデータベースエンジンごとに異なる Catalog を持つ。

PostgreSQL のスキーマ解析に使われる Catalog を生成する関数は以下で定義されている。引用はしないが、行数がすごいので一度見てみて欲しい。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/engine/postgresql/pg_catalog.go#L30178

Catalog では SQL から AST を構築するにあたり internal/sql/ast 以下に列挙されている SQL 文を表す構造体を使用する。例えば CREATE TABLE 文は crate_table_stmt.go で以下のように定義されている。

package ast

type CreateTableStmt struct {
	IfNotExists bool
	Name        *TableName
	Cols        []*ColumnDef
	ReferTable  *TableName
	Comment     string
	Inherits    []*TableName
}

func (n *CreateTableStmt) Pos() int {
	return 0
}

例えば CREATE TABLE 文で使われる IF NOT EXISTSIfNotExists として構造体のメンバーとして定義されている。pganalyze/pg_query_go の処理結果として取得した node を translate 関数でこれらの構造体にマッピングして sqlc 内でコードを生成するための AST に変換する。

ParseQueries について

catalog と同様 SQL 文を表す構造体は internal/sql/ast に格納されている。例えば SELECT 文は select_stmt.go で以下のように定義されている。

package ast

type SelectStmt struct {
	DistinctClause *List
	IntoClause     *IntoClause
	TargetList     *List
	FromClause     *List
	WhereClause    Node
	GroupClause    *List
	HavingClause   Node
	WindowClause   *List
	ValuesLists    *List
	SortClause     *List
	LimitOffset    Node
	LimitCount     Node
	LockingClause  *List
	WithClause     *WithClause
	Op             SetOperation
	All            bool
	Larg           *SelectStmt
	Rarg           *SelectStmt
}

func (n *SelectStmt) Pos() int {
	return 0
}

SELECT 文(Stmt は Statement の略?)で使われる DISTINCTGROUP BYORDER BY などが構造体のメンバーとして定義されている。

コードの生成

次は codegen 関数を見ていく。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L364-L412

コメントは筆者が挿入した。

func codegen(ctx context.Context, combo config.CombinedSettings, sql outPair, result *compiler.Result) (string, *plugin.CodeGenResponse, error) {
  // デバッグ用のトレースを設定する
	defer trace.StartRegion(ctx, "codegen").End()
  // Catalog などの設定を req オブジェクトにまとめる
	req := codeGenRequest(result, combo)
	var handler ext.Handler
	var out string
  // どの言語、方式でコード生成するかを選択する
	switch {
	case sql.Gen.Go != nil:
		out = combo.Go.Out
		handler = ext.HandleFunc(golang.Generate)

	case sql.Gen.JSON != nil:
		out = combo.JSON.Out
		handler = ext.HandleFunc(json.Generate)

	case sql.Plugin != nil:
		out = sql.Plugin.Out
		plug, err := findPlugin(combo.Global, sql.Plugin.Plugin)
		if err != nil {
			return "", nil, fmt.Errorf("plugin not found: %s", err)
		}

    // プラグインでコード生成する場合は Process か WASM かによってハンドラを設定する
		switch {
		case plug.Process != nil:
			handler = &process.Runner{
				Cmd: plug.Process.Cmd,
				Env: plug.Env,
			}
		case plug.WASM != nil:
			handler = &wasm.Runner{
				URL:    plug.WASM.URL,
				SHA256: plug.WASM.SHA256,
				Env:    plug.Env,
			}
		default:
			return "", nil, fmt.Errorf("unsupported plugin type")
		}

    // プラグインのオプションを JSON 形式に変換する
		opts, err := convert.YAMLtoJSON(sql.Plugin.Options)
		if err != nil {
			return "", nil, fmt.Errorf("invalid plugin options")
		}
		req.PluginOptions = opts

	default:
		return "", nil, fmt.Errorf("missing language backend")
	}
  // 選択したハンドラを使用してコードを生成する
	resp, err := handler.Generate(ctx, req)
	return out, resp, err
}

最後に handler.Generate で呼び出される関数は handler の設定によって異なる。Go ではこの Generate 関数が呼ばれる。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/codegen/golang/gen.go#L104

上記の Generate 関数が呼ぶ generate 関数では text/template で template からファイルを生成する。Go 版は /internal/codegen/golang/templates 以下に書かれており、embed パッケージを使って埋め込まれている。

デフォルトのテンプレートは /internal/codegen/golang/templatess/template.tmpl で、これを見ると sqlc が生成する .go ファイルに酷似していることが分かる。

最終的には ExecuteTemplate を使ってデータ(&tctx)を templateName のテンプレートを介して bufio に書き込んでいる。

コメントは筆者が挿入した。

	execute := func(name, templateName string) error {
		imports := i.Imports(name)
		replacedQueries := replaceConflictedArg(imports, queries)

		var b bytes.Buffer
		w := bufio.NewWriter(&b)
		tctx.SourceName = name
		tctx.GoQueries = replacedQueries
		err := tmpl.ExecuteTemplate(w, templateName, &tctx) // ここでテンプレートに沿ってテキストを生成し w を介して bufio に書き込んでいる
		w.Flush()
		if err != nil {
			return err
		}
		code, err := format.Source(b.Bytes())
		if err != nil {
			fmt.Println(b.String())
			return fmt.Errorf("source error: %w", err)
		}

		if templateName == "queryFile" && golang.OutputFilesSuffix != "" {
			name += golang.OutputFilesSuffix
		}

		if !strings.HasSuffix(name, ".go") {
			name += ".go"
		}
		output[name] = string(code)
		return nil
	}

エラーの処理と生成されたコードの返却

最後はエラー処理して output を返すだけ。

https://github.com/sqlc-dev/sqlc/blob/faa1c9d156272eea4d33a31de2ab94951ba6af58/internal/cmd/generate.go#L256-L267

	if err := grp.Wait(); err != nil {
		return nil, err
	}
	if errored {
		for i, _ := range stderrs {
			if _, err := io.Copy(stderr, &stderrs[i]); err != nil {
				return nil, err
			}
		}
		return nil, fmt.Errorf("errored")
	}
	return output, nil

まとめ

私自身まだ使い始めたばかりで把握していないことが多く自分自身が Go 初心者でもあるので、誤った認識や不足している記述があるかもしれない(あったらすみません)。

これから使っていく中で面白いことがあればまたブログに書いてみようと考えている。

sqlc

  • SQL ファイルから型安全な Go のデータベースクライアントやスキーマと対応した構造体を簡単に生成できて便利
  • 生成されるファイルを設定ファイルやプラグイン等で加工することができる

sqlc の導入については公式ドキュメントの他に以下のブログが参考になりそう。

プラグインについては以下のブログが参考になりそう。

sqlc generate コマンド

  • SQL ファイルからの生成は SQL の句ごと、データベースエンジン別に AST を組み立てるための Go の構造体が用意されており、それを参照して SQL を解析する
  • コードの生成は予め template が用意され embed されており、text/template を使ってデータを流し込む

Footnotes

  1. Go 以外にも Kotlin や Python 等がサポートされている https://docs.sqlc.dev/en/stable/reference/language-support.html