sqlcで始める型安全なGo製データベースアプリケーション開発

sqlcで始める型安全なGo製データベースアプリケーション開発

はじめに

Go言語でアプリケーションを開発する際、多くの場合はデータベースとの連携が必要不可欠となります。リレーショナルデータベースを扱う場合、SQLを使ってデータの操作を行うのが一般的です。しかし、Goのコードの中からSQLを実行する際には、いくつかの課題に直面することがあります。

最も一般的な課題の一つは「型安全性」です。Goのコードの中でSQL文字列を直接記述し、database/sql パッケージなどを使ってクエリを実行する場合、SQLの構文エラーやカラム名の間違い、データの型の不一致などは実行時になるまで検出されにくい傾向があります。これは開発効率を低下させ、本番環境での予期せぬエラーの原因となり得ます。

また、SQLクエリのパラメータを扱う際に、適切にエスケープ処理を行わないとSQLインジェクションの脆弱性を生む可能性があります。database/sql パッケージのプレースホルダ機能を使えばこの問題は回避できますが、注意深く実装する必要があります。

さらに、SQLクエリの結果をGoの構造体にマッピングする作業も手作業で行うことが多く、カラムの数が増えたり、JOINを含む複雑なクエリになったりすると、そのマッピングコードを書くのが非常に煩雑になり、ミスも発生しやすくなります。また、データベーススキーマが変更された場合、関連するSQLクエリとGoコードの両方を手作業で修正する必要があり、メンテナンスコストが高くなります。

これらの課題を解決するために、Goの世界では様々なアプローチが取られてきました。代表的なものにORマッパー(Object-Relational Mapper)があります。GORMやEntといったORマッパーは、Goの構造体をテーブルに見立てて、Goのコードだけでデータベース操作を行えるように抽象化を提供します。これにより、多くの場合はSQLを直接書く必要がなくなり、型安全な開発が可能になります。

しかし、ORマッパーも万能ではありません。生成されるSQLが開発者の意図と異なる場合があったり、複雑なクエリやデータベース固有の高度な機能を活用したい場合にORマッパーの抽象化が邪魔になったりすることがあります。また、ORマッパー自体の学習コストも存在します。

そこで注目されているのが、SQLコードジェネレーターである sqlc です。sqlcは、開発者が書いたSQLファイル(スキーマ定義とクエリ定義)を読み込み、それに対応するGoのコード(SQLを実行するための関数や、結果を保持するための構造体など)を自動生成します。

sqlcを使うことで、開発者はSQLをそのまま利用できるというメリットを享受しつつ、以下の利点を得られます。

  • 型安全なデータベースアクセス: 生成されるGoコードは、SQLのパラメータや結果の型に基づいて厳密に型付けされます。これにより、コンパイル時に型の不一致や存在しないカラムへのアクセスといったエラーを検出できるようになります。
  • 開発効率の向上: SQLファイルとGoコードのマッピング、パラメータのバインディング、結果の構造体への詰め替えといった定型的な作業が自動化されます。
  • メンテナンス性の向上: スキーマやクエリを変更した場合、sqlcを再実行するだけで関連するGoコードが更新されます。手作業での修正箇所が減り、変更漏れを防ぎやすくなります。
  • SQLインジェクション対策: sqlcが生成するコードは、適切なプレースホルダを使った安全な方法でパラメータを扱います。
  • パフォーマンス: ORマッパーのように実行時に複雑な抽象化レイヤーを介さないため、生成されるコードは効率的であり、開発者が意図したSQLがそのまま実行されます。

この記事では、sqlcを使ってGo製データベースアプリケーションを開発する具体的な方法について、詳細かつ網羅的に解説します。sqlcの導入から設定、SQLファイルの書き方、コード生成の仕組み、そして生成されたコードをアプリケーションで活用する方法まで、ステップバイステップで説明していきます。約5000語というボリュームを活かし、各要素を深く掘り下げていきますので、sqlcを使った開発に興味がある方はぜひ最後までお読みください。

sqlcとは何か?

sqlcは、Go言語(および他の言語)向けのSQLコードジェネレーターです。そのコアとなる機能は、開発者が記述したSQLスキーマファイルとSQLクエリファイルを解析し、それらに基づいて型安全なGoコードを自動生成することです。

sqlcの基本的なワークフローは以下のようになります。

  1. スキーマ定義: データベースのテーブル構造などをSQLで定義します (schema.sql など)。
  2. クエリ定義: アプリケーションが必要とするデータベース操作(SELECT, INSERT, UPDATE, DELETEなど)をSQLで定義します (queries.sql など)。
  3. 設定ファイル: sqlc.yaml ファイルで、入力となるSQLファイル、出力先のディレクトリ、生成するGoコードのパッケージ名、利用するデータベースエンジンなどの設定を記述します。
  4. コード生成: コマンドラインから sqlc generate コマンドを実行します。
  5. 生成されたコードの利用: 生成されたGoコードを、アプリケーションのソースコードから呼び出してデータベース操作を行います。

sqlcはORマッパーとは根本的に異なります。ORマッパーがGoの構造体とデータベースのテーブル間のマッピングを抽象化し、Goのコードでクエリを組み立てるのに対し、sqlcは開発者が書いた生のSQLクエリを尊重し、そのクエリを実行するためのGoコードを生成するツールです。つまり、sqlcは「SQLファースト」なアプローチを取ります。SQLの知識がそのまま活かせ、データベースの機能を最大限に引き出す自由度があります。

sqlcの主なメリットを改めて整理しましょう。

  • 型安全: SQLの各クエリに対して、入出力の型が明確に定義されたGo関数が生成されます。これにより、間違った型の値を渡したり、存在しないカラムにアクセスしたりといったエラーをコンパイル時に検出できます。
  • 開発効率: 定型的なSQL実行コード、パラメータバインディング、結果マッピングコードの手書きが不要になります。
  • メンテナンス性: スキーマやクエリの変更があっても、sqlcを再実行するだけで対応するGoコードが更新されます。手作業での修正箇所が減り、人的ミスを防ぎます。
  • パフォーマンス: 生成されるコードは薄いラッパーであり、database/sql パッケージなどの標準的なドライバを直接利用します。ORマッパーのような実行時のオーバーヘッドは最小限です。開発者が書いたSQLがそのまま実行されるため、クエリのパフォーマンスチューニングも容易です。
  • 可読性: SQLクエリはGoコードの中に文字列リテラルとして埋め込まれるのではなく、独立したSQLファイルに記述されます。これにより、SQLとGoコードが分離され、それぞれの可読性が向上します。
  • SQLインジェクション対策: 生成コードは必ずプレースホルダを利用するため、安全なパラメータバインディングが行われます。

デメリットとしては、SQLファイルを別途管理する必要があること、動的に構造が変化するようなクエリには向かないこと(ただし、一部の動的な部分はGoコードで補完することも可能です)、そしてsqlc自体の設定ファイル(sqlc.yaml)の記述方法を学ぶ必要があることが挙げられます。しかし、これらのデメリットは、得られるメリットと比較すれば十分に許容できる範囲にあると言えるでしょう。

sqlcは特に、複雑なSQLクエリを多用するアプリケーションや、パフォーマンスが重視されるアプリケーション、あるいは既存のSQL資産を活かしたいプロジェクトなどに適しています。

sqlcの導入とセットアップ

sqlcを使い始めるための導入とプロジェクトセットアップ手順を説明します。

1. 前提条件

  • Goがインストールされていること(Go 1.13以降を推奨)。
  • 使用するデータベース(例: PostgreSQL, MySQL, SQLite)がセットアップされており、接続可能であること。

2. sqlcのインストール

sqlcはGoのツールとしてインストールできます。以下のコマンドを実行してください。

bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

これで sqlc コマンドが $GOPATH/bin または $GOBIN にインストールされます。環境変数 PATH$GOPATH/bin または $GOBIN が含まれていることを確認してください。インストールが成功したか確認するには、以下のコマンドを実行します。

bash
sqlc version

バージョン情報が表示されればインストール成功です。

3. プロジェクトのセットアップ

新しいGoプロジェクトを作成するか、既存のプロジェクト内でデータベースアクセスのためのディレクトリ構造を用意します。一般的な構成例としては、以下のようなものがあります。

my-awesome-app/
├── go.mod
├── main.go
└── db/
├── sqlc/
│ └── sqlc.yaml # sqlc の設定ファイル
└── queries/
└── user.sql # SQL クエリ ファイル
└── schema/
└── schema.sql # データベース スキーマ ファイル
└── models.go # 生成される Go コードの出力先ディレクトリ (例)
└── db.go # 生成される Go コードの出力先ディレクトリ (例)

この例では、db/sqlc ディレクトリに設定ファイルを置き、db/queries にクエリファイル、db/schema にスキーマファイルを置く構成にしています。生成されるGoコードは db/ ディレクトリ直下に出力されるように設定します。ディレクトリ構成はプロジェクトの規模や好みに合わせて変更できますが、スキーマとクエリをまとめるディレクトリと、生成コードの出力先ディレクトリを明確に分けておくことを推奨します。

4. sqlc.yaml ファイルの作成と設定

sqlc.yaml (または sqlc.yml) はsqlcの設定を行うための最も重要なファイルです。プロジェクトのルートディレクトリ、または上記例のように特定のサブディレクトリに作成します。

以下に、基本的な sqlc.yaml の設定例を示します。

“`yaml
version: “2” # sqlc の設定ファイルフォーマットのバージョンを指定

packages:
– name: “db” # 生成される Go パッケージ名
path: “./db” # 生成される Go コードの出力先ディレクトリ
queries: “./db/queries/” # SQL クエリ ファイルが置かれているディレクトリ
schema: “./db/schema/” # データベース スキーマ ファイルが置かれているディレクトリ
engine: “postgresql” # 使用するデータベース エンジン (postgresql, mysql, sqlite3 など)
emit_json_tags: true # 生成される構造体に JSON タグを付与するかどうか
emit_prepared_queries: false # プリペアドステートメントを生成するかどうか
emit_interface: true # 生成される Queries struct に対してインターフェースを生成するかどうか
emit_exact_table_names: false # モデル名にテーブル名をそのまま使用するかどうか (複数形にならないようにするなど)
emit_empty_slices: false # クエリ結果が空の場合に nil スライスではなく空のスライスを生成するかどうか
json_tags_case_style: “camel” # JSON タグの命名規則 (camel, snake, Pascal)
# go:
# sql_package: “database/sql” # 使用する SQL パッケージ (database/sql or pgx/v5)
# overrides: # 型マッピングのカスタマイズ
# – db_type: “pg_catalog.uuid”
# go_type: “github.com/gofrs/uuid.UUID”
# nullable: false
“`

各設定項目の詳細について説明します。

  • version: "2": sqlc.yaml の設定ファイルフォーマットのバージョンを指定します。現在推奨されているのはバージョン 2 です。
  • packages: これはリストであり、複数のパッケージ設定を定義できます。一般的には一つのアプリケーションに対して一つの設定で十分ですが、マイクロサービスのように異なるデータベースやスキーマに対して個別のコードを生成したい場合に便利です。
    • name: 生成されるGoコードのパッケージ名です。例えば db と指定すると、生成されるGoファイルは package db となります。
    • path: 生成されるGoコードを格納するディレクトリのパスです。プロジェクトルートからの相対パスで指定します。上記の例では ./db に生成されます。
    • queries: SQLクエリファイルが置かれているディレクトリ、または単一のファイルパスを指定します。複数のディレクトリを指定することも可能です。
    • schema: データベーススキーマファイル(CREATE TABLE 文などが含まれる)が置かれているディレクトリ、または単一のファイルパスを指定します。
    • engine: 使用するデータベースエンジンを指定します。postgresqlmysqlsqlite3 などがサポートされています。この設定によって、sqlcは各データベースのSQL方言やデータ型を理解してコードを生成します。
    • emit_json_tags: true にすると、生成されるGo構造体のフィールドにJSONタグ(例: `json:"field_name"`)が付与されます。APIサーバーなどでJSONレスポンスを生成する際に便利です。
    • emit_prepared_queries: true にすると、生成されるクエリ関数がプリペアドステートメントを使用するようになります。頻繁に実行されるクエリの場合、パフォーマンスが向上する可能性がありますが、データベースによっては注意が必要です。デフォルトは false です。
    • emit_interface: true にすると、生成される Queries struct に対応するインターフェース (Querier) が生成されます。これにより、データベース操作のモックを作成しやすくなり、テストやDI(依存性注入)が容易になります。
    • emit_exact_table_names: true にすると、生成されるモデル名がテーブル名の単数形化などをせずにそのまま使われます。例えば users テーブルに対してデフォルトでは User モデルが生成されますが、これを User または Users のように制御できます。
    • emit_empty_slices: true にすると、SELECTクエリの結果が空の場合に、nil スライスではなく空のスライス(例: []User{})が返されるようになります。
    • json_tags_case_style: emit_json_tagstrue の場合、JSONタグのフィールド名の命名規則を指定します。camel (camelCase), snake (snake_case), Pascal (PascalCase) から選択できます。デフォルトは camel です。
    • go: Go言語固有の設定をネストして記述します。
      • sql_package: 生成コードで使用するSQLパッケージを指定します。database/sql または pgx/v5(PostgreSQLの場合)などを指定できます。pgx/v5 を使うと、pgtype パッケージの型が利用できるなど、PostgreSQL固有の型をより自然に扱えます。
      • overrides: SQLのデータ型とGoのデータ型のマッピングをカスタマイズするための設定です。デフォルトのマッピングが適切でない場合や、特定のカスタム型を使いたい場合に利用します。リスト形式で、db_type (SQL型) と go_type (Go型) を指定します。nullable でそのGo型がNULL可能な値を表現できるかどうかも指定できます。例えば、UUID型のカラムを標準の string ではなく github.com/gofrs/uuid.UUID にマッピングするといった使い方ができます。

これらの設定を記述した sqlc.yaml ファイルをプロジェクトに配置します。

5. 対応データベース

sqlcは様々なリレーショナルデータベースをサポートしています。sqlc.yamlengine 設定で指定します。

  • PostgreSQL (postgresql)
  • MySQL (mysql)
  • SQLite (sqlite3)
  • SQL Server (sqlserver)
  • Oracle Database (oracle)

使用するデータベースに応じて適切な engine を指定してください。これにより、sqlcはそのデータベースのSQLシンタックスやデータ型を正確に解析し、適切なGoコードを生成できます。

これで、sqlcを使うための基本的なセットアップは完了です。次に、スキーマファイルとクエリファイルの書き方を見ていきましょう。

SQLファイルの作成

sqlcがコードを生成するための元となるのは、SQLファイルです。主に、データベースの構造を定義する「スキーマファイル」と、アプリケーションが実行する具体的な操作を定義する「クエリファイル」の2種類を作成します。

1. スキーマ定義 (schema.sql)

スキーマファイルには、テーブルの作成 (CREATE TABLE)、インデックスの作成 (CREATE INDEX)、ビューの作成 (CREATE VIEW) など、データベースの構造を定義するSQL文を記述します。sqlcはこれらの定義を解析して、対応するGoの構造体(モデル)を生成します。

例として、簡単なユーザー管理のためのスキーマを定義してみましょう。

“`sql
— db/schema/schema.sql

CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
order_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
total_amount DECIMAL(10, 2) NOT NULL
);

CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL,
unit_price DECIMAL(10, 2) NOT NULL
);

— Index for faster lookup on user_id in orders table
CREATE INDEX idx_orders_user_id ON orders (user_id);

— Index for faster lookup on order_id in order_items table
CREATE INDEX idx_order_items_order_id ON order_items (order_id);
“`

このスキーマファイルには、users, products, orders, order_items という4つのテーブル定義が含まれています。sqlcはこれらのテーブル定義を読み込み、それぞれに対応するGo構造体を生成します。例えば、users テーブルからは User という名前の構造体が生成されるでしょう(命名規則は設定で変更可能)。各カラムのSQL型は、Goの適切な型にマッピングされます。

SQLデータ型とGoデータ型のマッピング

sqlcは、指定されたデータベースエンジンに基づいて、SQLのデータ型をGoのデータ型に自動的にマッピングします。一般的なマッピングの例(PostgreSQLの場合)をいくつか示します。

PostgreSQL Type database/sql Go Type pgx/v5 Go Type (if sql_package is pgx/v5)
BIGINT, SERIAL int64 int64
INTEGER, SERIAL int32 int32
SMALLINT, SMALLSERIAL int16 int16
BOOLEAN bool bool
VARCHAR, TEXT string string
DECIMAL, NUMERIC string (or float64) pgtype.Numeric (or float64)
REAL, DOUBLE PRECISION float64 float64
BYTEA []byte []byte
DATE, TIMESTAMP time.Time time.Time
JSON, JSONB []byte pgtype.JSONB
UUID string pgtype.UUID
NULLABLE TYPE sql.NullType Pointer to Go Type (e.g., *string) or pgtype.Type

NULLABLEなカラム(例: description TEXT)に対しては、デフォルトでは database/sqlsql.NullStringsql.NullTime のような型が生成されます。sql.NullStringString stringValid bool のフィールドを持ち、Validfalse の場合にSQLのNULLを表します。

sqlc.yamlgo.sql_packagepgx/v5 に設定した場合、PostgreSQL固有の型(UUID, JSONB など)は pgtype パッケージの対応する型にマッピングされ、NULLABLEな型はポインター(例: *string)または pgtype の型として扱われるなど、よりGoらしい扱いや豊富な型情報が得られます。

また、go.overrides 設定を使うことで、これらのデフォルトマッピングをカスタマイズできます。例えば、UUIDを標準の string ではなく、UUID専用のライブラリ(例: github.com/gofrs/uuid)の型にマッピングするといったことが可能です。

“`yaml

sqlc.yaml (overrides 例)

go:
sql_package: “database/sql”
overrides:
– db_type: “pg_catalog.uuid”
go_type:
import: “github.com/gofrs/uuid”
package: “uuid”
type: “UUID”
nullable: false # UUIDカラムが NOT NULL の場合
– db_type: “pg_catalog.uuid”
go_type:
import: “github.com/gofrs/uuid”
package: “uuid”
type: “UUID”
nullable: true # UUIDカラムが NULLABLE の場合 (ポインター型になる)
“`

NULLABLEな型に対する go_type の指定方法は、nullable: true と組み合わせることでポインター型を生成させることが一般的です。

2. クエリ定義 (queries.sql)

クエリファイルには、アプリケーションが実際に実行するSQLクエリを記述します。sqlcはこれらのクエリを解析し、それぞれに対応するGo関数を生成します。

クエリファイル内でsqlcが特別な意味を持つコメントは以下の通りです。

  • -- name: QueryName : これに続くSQLクエリに名前を付けます。sqlcはこの名前を使ってGoの関数名を生成します。必ずSQLクエリの直前に記述します。
  • -- :one : クエリの結果が単一の行(または結果なし)であることをsqlcに伝えます。生成されるGo関数は単一の構造体(またはnilとエラー)を返します。SELECTクエリに主に使われます。
  • -- :many : クエリの結果が複数行であることをsqlcに伝えます。生成されるGo関数は構造体のスライスを返します。SELECTクエリに主に使われます。
  • -- :exec : INSERT, UPDATE, DELETE のような結果セットを返さないSQLクエリであることをsqlcに伝えます。生成されるGo関数はエラーのみを返します。
  • -- :execrows : -- :exec と似ていますが、影響を受けた行数 (sql.Result.RowsAffected()) を取得したい場合に指定します。生成されるGo関数は int64 (影響行数) とエラーを返します。
  • -- :execresult : -- :exec と似ていますが、sql.Result オブジェクト全体を取得したい場合に指定します。生成されるGo関数は sql.Result とエラーを返します。
  • -- :batch : 複数の同じクエリをまとめて実行するバッチ処理を生成する場合に指定します。

パラメータは、$1, $2, … のように番号付きプレースホルダを使用するか、: で始まる名前付きパラメータ(:name, :age など)を使用できます。sqlc.yaml の設定(デフォルトは番号付き)でどちらを使うか指定します。

例として、上記スキーマに対するいくつかのクエリを定義してみましょう。

“`sql
— db/queries/user.sql

— name: CreateUser :one
INSERT INTO users (username, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, username, email, password_hash, created_at; — INSERT と同時に挿入された行を取得

— name: GetUserById :one
SELECT id, username, email, password_hash, created_at
FROM users
WHERE id = $1 LIMIT 1;

— name: GetUserByEmail :one
SELECT id, username, email, password_hash, created_at
FROM users
WHERE email = $1 LIMIT 1;

— name: ListUsers :many
SELECT id, username, email, password_hash, created_at
FROM users
ORDER BY username;

— name: UpdateUserPassword :exec
UPDATE users
SET password_hash = $2
WHERE id = $1;

— name: DeleteUser :execrows
DELETE FROM users
WHERE id = $1;

— name: CreateProduct :one
INSERT INTO products (name, description, price)
VALUES ($1, $2, $3)
RETURNING id, name, description, price, created_at;

— name: ListProducts :many
SELECT id, name, description, price, created_at
FROM products
ORDER BY name;

— name: CreateOrder :one
INSERT INTO orders (user_id, total_amount)
VALUES ($1, $2)
RETURNING id, user_id, order_date, total_amount;

— name: CreateOrderItem :one
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES ($1, $2, $3, $4)
RETURNING id, order_id, product_id, quantity, unit_price;

— name: GetOrderWithItems :many
SELECT
o.id AS order_id,
o.user_id,
o.order_date,
o.total_amount AS order_total,
oi.id AS order_item_id,
oi.product_id,
oi.quantity,
oi.unit_price AS item_unit_price,
p.name AS product_name
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = $1
ORDER BY oi.id;
“`

このクエリファイルでは、各SQLクエリの上に -- name: QueryName-- :one または -- :many, -- :exec などの指示コメントを付けています。パラメータは $1, $2 のように番号付きで指定しています。

CreateUser クエリのように RETURNING 句を使うと、INSERTされた行のデータをまとめて取得できます。sqlcは RETURNING 句がある場合、指定されたカラムを含む構造体を返す関数を生成します。

GetOrderWithItems クエリのようにJOINを使う場合、結果のカラム名は重複しないようにエイリアスを付けることが推奨されます。sqlcはこれらのエイリアス名を使って生成されるGo構造体のフィールド名を決定します。

SQLコメント (-- ...) は、クエリの説明などを書くのに使えます。sqlcは -- name: などの特別なコメント以外は無視します。

スキーマファイルとクエリファイルが準備できたら、いよいよコード生成に進みます。

コード生成と生成されたコードの理解

sqlc.yaml、スキーマファイル、クエリファイルが準備できたら、sqlc generate コマンドを実行してGoコードを生成します。

プロジェクトルートディレクトリ(または sqlc.yaml が置かれているディレクトリ)で、以下のコマンドを実行します。

bash
sqlc generate

エラーがなければ、sqlc.yamlpackages.path に指定したディレクトリ(上記例では ./db)にGoのソースファイルが生成されます。

生成されるファイルとディレクトリ構造

上記の例の sqlc.yaml とSQLファイル構成の場合、以下のようなファイルが生成されるはずです。

my-awesome-app/
└── db/
├── sqlc/
│ └── sqlc.yaml
└── queries/
│ └── user.sql
└── schema/
│ └── schema.sql
├── models.go # スキーマに対応する Go 構造体
├── queries.sql.go # クエリに対応する Go 関数 (ファイル名はクエリファイル名に .sql.go が付くことが多い)
├── db.go # Queries オブジェクトのファクトリ関数など (emit_interface: true の場合 Querier インターフェースも)
└── ... (他の生成ファイル)

生成されるファイル名は、sqlcのバージョンや設定によって若干異なる場合があります。queries.sql.go のようなファイルには、queries/user.sql で定義した各クエリに対応するGo関数が含まれます。models.go には、schema/schema.sql で定義したテーブルに対応するGo構造体が定義されます。db.go は、データベース接続と生成されたクエリ関数をまとめる Queries オブジェクトを作成するためのヘルパー関数などを含みます。emit_interface: true の場合、このファイルに Querier インターフェースも定義されます。

生成されるGoコードの詳細な解説

生成されたGoコードの中身を見ていきましょう。

models.go

スキーマファイルで定義したテーブルごとにGo構造体が生成されます。カラム名は、SQLのsnake_caseからGoのPascalCaseに変換されます(これも設定で変更可能)。SQLのデータ型は、Goの適切な型にマッピングされます。

“`go
// db/models.go (生成例、一部抜粋)

// Represents a row from ‘users’.
type User struct {
ID int64 json:"id"
Username string json:"username"
Email string json:"email"
PasswordHash string json:"password_hash"
CreatedAt time.Time json:"created_at"
}

// Represents a row from ‘products’.
type Product struct {
ID int64 json:"id"
Name string json:"name"
Description sql.NullString json:"description" // NULLABLE な TEXT カラムは sql.NullString に
Price string json:"price" // DECIMAL はデフォルトで string に (または float64 や pgtype.Numeric)
CreatedAt time.Time json:"created_at"
}

// Represents a row from ‘orders’.
type Order struct {
ID int64 json:"id"
UserID int64 json:"user_id"
OrderDate time.Time json:"order_date"
TotalAmount string json:"total_amount" // DECIMAL はデフォルトで string に
}

// Represents a row from ‘order_items’.
type OrderItem struct {
ID int64 json:"id"
OrderID int64 json:"order_id"
ProductID int64 json:"product_id"
Quantity int32 json:"quantity" // INT は int32 に
UnitPrice string json:"unit_price" // DECIMAL は string に
}
“`

上記の例では、emit_json_tags: true かつ json_tags_case_style: "snake" (またはデフォルトの camel でsnake_caseを保つ設定) の場合にJSONタグが付与されています。description カラムは TEXT でNULLABLEだったため、sql.NullString 型になっています。DECIMAL 型はデフォルトでは string にマッピングされることが多いですが、これはデータベースや設定によって異なります。

queries.sql.go (またはクエリファイル名に対応したファイル)

クエリファイルで定義した各SQLクエリに対応するGo関数が生成されます。これらの関数は、Queries という構造体のメソッドとして定義されます。

“`go
// db/queries.sql.go (生成例、一部抜粋)

import (
“context”
“database/sql”
“time”
)

// Querier defines the data access methods generated by sqlc.
// This interface is generated if emit_interface is true in sqlc.yaml.
type Querier interface {
CreateUser(ctx context.Context, arg CreateUserParams) (User, error)
GetUserById(ctx context.Context, id int64) (User, error)
GetUserByEmail(ctx context.Context, email string) (User, error)
ListUsers(ctx context.Context) ([]User, error)
UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error
DeleteUser(ctx context.Context, id int64) (int64, error) // returns RowsAffected
CreateProduct(ctx context.Context, arg CreateProductParams) (Product, error)
ListProducts(ctx context.Context) ([]Product, error)
CreateOrder(ctx context.Context, arg CreateOrderParams) (Order, error)
CreateOrderItem(ctx context.Context, arg CreateOrderItemParams) (OrderItem, error)
GetOrderWithItems(ctx context.Context, id int64) ([]GetOrderWithItemsRow, error) // JOIN 結果用のカスタム構造体
}

type Queries struct {
db DBTX
}

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

// DBTX is an interface that wraps the common methods of sql.DB and sql.Tx.
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
}

// — クエリ関数例 —

// CreateUserParams contains the parameters for the CreateUser query.
type CreateUserParams struct {
Username string
Email string
PasswordHash string
}

func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
row := q.db.QueryRowContext(ctx, -- name: CreateUser :one
INSERT INTO users (username, email, password_hash)
VALUES ($1, $2, $3)
RETURNING id, username, email, password_hash, created_at
, arg.Username, arg.Email, arg.PasswordHash)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.Email,
&i.PasswordHash,
&i.CreatedAt,
)
return i, err
}

// GetUserById is a generated query method.
func (q *Queries) GetUserById(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRowContext(ctx, -- name: GetUserById :one
SELECT id, username, email, password_hash, created_at
FROM users
WHERE id = $1 LIMIT 1
, id)
var i User
err := row.Scan(
&i.ID,
&i.Username,
&i.Email,
&i.PasswordHash,
&i.CreatedAt,
)
return i, err
}

// ListUsers is a generated query method.
func (q *Queries) ListUsers(ctx context.Context) ([]User, error) {
rows, err := q.db.QueryContext(ctx, -- name: ListUsers :many
SELECT id, username, email, password_hash, created_at
FROM users
ORDER BY username
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []User{}
for rows.Next() {
var i User
if err := rows.Scan(
&i.ID,
&i.Username,
&i.Email,
&i.PasswordHash,
&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
}

// GetOrderWithItemsRow represents a row from ‘GetOrderWithItems’.
// This structure is generated for queries that return columns from multiple tables (e.g., JOINs).
type GetOrderWithItemsRow struct {
OrderID int64 json:"order_id"
UserID int64 json:"user_id"
OrderDate time.Time json:"order_date"
OrderTotal string json:"order_total"
OrderItemID int64 json:"order_item_id"
ProductID int64 json:"product_id"
Quantity int32 json:"quantity"
ItemUnitPrice string json:"item_unit_price"
ProductName string json:"product_name"
}

func (q *Queries) GetOrderWithItems(ctx context.Context, id int64) ([]GetOrderWithItemsRow, error) {
rows, err := q.db.QueryContext(ctx, -- name: GetOrderWithItems :many
SELECT
o.id AS order_id,
o.user_id,
o.order_date,
o.total_amount AS order_total,
oi.id AS order_item_id,
oi.product_id,
oi.quantity,
oi.unit_price AS item_unit_price,
p.name AS product_name
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.id = $1
ORDER BY oi.id
, id)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetOrderWithItemsRow{}
for rows.Next() {
var i GetOrderWithItemsRow
if err := rows.Scan(
&i.OrderID,
&i.UserID,
&i.OrderDate,
&i.OrderTotal,
&i.OrderItemID,
&i.ProductID,
&i.Quantity,
&i.ItemUnitPrice,
&i.ProductName,
); 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
}
“`

  • Queries struct: 生成されたすべてのクエリメソッドを保持する構造体です。この構造体は、データベース接続 (DBTX インターフェース)をフィールドとして持ちます。
  • New function: Queries struct のインスタンスを生成するためのファクトリ関数です。この関数に *sql.DB または *sql.Tx を渡して Queries オブジェクトを作成します。
  • DBTX interface: database/sql.DBdatabase/sql.Tx の両方が実装する、必要なデータベース操作メソッド(ExecContext, QueryContext, QueryRowContext など)を定義したインターフェースです。これにより、トランザクション内外で同じ Queries オブジェクトを利用できるようになります。
  • クエリ関数: 各クエリに対応して生成される関数です。関数名は -- name: で指定した名前が使われます。

    • すべてのクエリ関数は第一引数に context.Context を取ります。これはキャンセルやタイムアウトなどのコンテキスト伝播に重要です。
    • クエリにパラメータがある場合、それらは関数の引数として渡されます。パラメータが複数の場合や、構造体で渡したい場合は、sqlcが自動的にパラメータ用の構造体を生成します(例: CreateUserParams)。
    • -- :one を指定したSELECTクエリは、単一の構造体とエラーを返します。結果がない場合は sql.ErrNoRows エラーが返されます。
    • -- :many を指定したSELECTクエリは、構造体のスライスとエラーを返します。
    • -- :exec を指定したクエリは、エラーのみを返します。
    • -- :execrows を指定したクエリは、影響を受けた行数 (int64) とエラーを返します。
    • -- :execresult を指定したクエリは、sql.Result とエラーを返します。
    • JOINなどによって複数のテーブルからカラムを取得するSELECTクエリの場合、sqlcは結果のカラムセットに対応する新しい構造体を生成します(例: GetOrderWithItemsRow)。
    • 生成された関数の中では、元のSQLクエリが文字列リテラルとして埋め込まれており、database/sql パッケージの適切なメソッド(QueryRowContext, QueryContext, ExecContext など)が呼び出されています。結果の *sql.Row*sql.Rows からデータを読み取り、適切なGoの型にスキャンして、生成された構造体に格納する処理も自動生成されます。
  • Querier interface: emit_interface: true の場合に生成されるインターフェースです。生成されたすべてのクエリメソッドのシグネチャを定義します。これにより、依存性注入を行う際に具象型である Queries struct ではなく、このインターフェースを依存として受け入れるように設計できます。これはテスト時にモック実装に差し替えたりする際に非常に便利です。

生成されたコードは基本的に読み取り専用として扱います。手作業で修正するのではなく、SQLファイルまたは sqlc.yaml を修正して再度 sqlc generate を実行することでコードを更新します。

生成コードを理解することは、sqlcがどのように機能し、生成された関数をアプリケーションでどのように活用できるかを把握するために重要です。

生成されたコードを使ったアプリケーション開発

生成されたGoコードをアプリケーションから呼び出して、実際にデータベース操作を行う方法を解説します。

1. データベース接続の確立

まず、アプリケーションの起動時にデータベースへの接続を確立します。標準の database/sql パッケージ、あるいはより高機能なドライバ(PostgreSQLなら pgx など)を使用します。sqlc.yamlgo.sql_package 設定で使用するパッケージを指定したことを思い出してください。

database/sql を使う場合:

“`go
import (
“database/sql”
“log”
_ “github.com/lib/pq” // PostgreSQL ドライバのインポート (他の DB なら該当ドライバ)
)

var db *sql.DB

func init() {
// 環境変数などから接続情報を取得することを推奨
connStr := “user=user password=password dbname=dbname sslmode=disable”
var err error
db, err = sql.Open(“postgres”, connStr) // “postgres” はドライバ名
if err != nil {
log.Fatal(err)
}

// データベースへの接続性を確認
err = db.Ping()
if err != nil {
    log.Fatal(err)
}

log.Println("Database connected successfully!")

}
“`

pgx/v5 を使う場合 (sqlc.yamlsql_package: "pgx/v5" と設定):

“`go
import (
“context”
“log”

"github.com/jackc/pgx/v5/pgxpool"

)

var pool *pgxpool.Pool

func init() {
ctx := context.Background()
// 環境変数などから接続情報を取得することを推奨
connStr := “postgres://user:password@host:port/dbname?sslmode=disable”
var err error
pool, err = pgxpool.New(ctx, connStr)
if err != nil {
log.Fatalf(“Unable to create connection pool: %v\n”, err)
}

// データベースへの接続性を確認
err = pool.Ping(ctx)
if err != nil {
    log.Fatalf("Database connection failed: %v\n", err)
}

log.Println("Database connected successfully!")

}
“`

pgxpool を使うと、コネクションプーリングがデフォルトで有効になるため、database/sql よりも推奨されることが多いです。

2. Queries オブジェクトの作成

データベース接続が確立できたら、生成された db.New() 関数(または sqlc.New() など、生成コードのエントリポイントとなる関数)を使って Queries オブジェクトを作成します。

database/sql を使う場合:

“`go
import (
“context”
// … その他のインポート
“my-awesome-app/db” // 生成された db パッケージをインポート
)

var queries *db.Queries

func init() {
// init 関数内で db 接続を確立した後、または main 関数などで
// db は前のステップで確立した *sql.DB
queries = db.New(db)
}
“`

pgx/v5 を使う場合:

“`go
import (
“context”
// … その他のインポート
“my-awesome-app/db” // 生成された db パッケージをインポート
)

var queries *db.Queries

func init() {
// init 関数内で pool 接続を確立した後、または main 関数などで
// pool は前のステップで確立した *pgxpool.Pool
queries = db.New(pool) // pgxpool.Pool は sqlc の DBTX インターフェースを満たす
}
“`

これで、queries 変数を通じて、生成されたすべてのクエリ関数にアクセスできるようになります。

sqlc.yamlemit_interface: true にしている場合は、db.Querier インターフェースを保持する変数として宣言することもできます。

“`go
var querier db.Querier

func init() {
// … db 接続確立 …
querier = db.New(db) // db.New() は *db.Queries を返し、これは db.Querier インターフェースを満たす
}
“`

このインターフェースを利用することで、コードの疎結合性が高まります。

3. 生成されたクエリ関数の呼び出し方

queries (または querier) オブジェクトを通じて、生成されたクエリ関数を呼び出します。

SELECT (一つ取得)

GetUserById のように -- :one を指定したクエリは、単一の構造体とエラーを返します。

go
func GetUser(ctx context.Context, userID int64) (*db.User, error) {
user, err := queries.GetUserById(ctx, userID)
if err != nil {
if err == sql.ErrNoRows {
// ユーザーが見つからない場合
return nil, nil // または 特定のエラー型
}
// その他のエラー
return nil, fmt.Errorf("failed to get user by ID: %w", err)
}
return &user, nil
}

結果が見つからない場合は sql.ErrNoRows が返されることに注意してください。pgx/v5 を使っている場合は pgx.ErrNoRows が返されます。

SELECT (複数取得)

ListUsers のように -- :many を指定したクエリは、構造体のスライスとエラーを返します。

“`go
func ListAllUsers(ctx context.Context) ([]db.User, error) {
users, err := queries.ListUsers(ctx)
if err != nil {
return nil, fmt.Errorf(“failed to list users: %w”, err)
}
// 結果が0件の場合、デフォルトでは nil スライスが返される。
// emit_empty_slices: true の場合は空のスライス ([]db.User{}) が返される。
return users, nil
}

// パラメータ付きの複数取得例
func GetOrderItems(ctx context.Context, orderID int64) ([]db.GetOrderWithItemsRow, error) {
items, err := queries.GetOrderWithItems(ctx, orderID)
if err != nil {
return nil, fmt.Errorf(“failed to get order items for order %d: %w”, orderID, err)
}
return items, nil
}
“`

INSERT

CreateUser のように -- :one を指定し、RETURNING 句を含むINSERTクエリは、挿入された行のデータを含む構造体とエラーを返します。パラメータが複数ある場合は、自動生成されたパラメータ構造体を使用します(CreateUserParams)。

“`go
func CreateNewUser(ctx context.Context, username, email, passwordHash string) (*db.User, error) {
arg := db.CreateUserParams{
Username: username,
Email: email,
PasswordHash: passwordHash,
}
user, err := queries.CreateUser(ctx, arg)
if err != nil {
// 一意性制約違反などのエラー処理が必要な場合がある
return nil, fmt.Errorf(“failed to create user: %w”, err)
}
return &user, nil
}

// RETURNING 句がない INSERT で、主キーなどを取得したい場合
// -- name: CreateProduct :one として RETURNING id; のように書くと、
// id (int64) とエラーを返す関数が生成される。
func CreateNewProduct(ctx context.Context, name, description string, price float64) (*db.Product, error) {
arg := db.CreateProductParams{
Name: name,
// description は NULLABLE なので sql.NullString を使う必要がある
Description: sql.NullString{String: description, Valid: description != “”},
Price: fmt.Sprintf(“%.2f”, price), // DECIMAL を string に変換する必要がある場合
}
product, err := queries.CreateProduct(ctx, arg)
if err != nil {
return nil, fmt.Errorf(“failed to create product: %w”, err)
}
return &product, nil
}
“`

NULLABLEなカラム(例: description)を扱うパラメータ構造体では、sql.NullString のような型を使用する必要があります。値を設定する場合は Validtrue に、NULLにする場合は Validfalse に設定します。

UPDATE

UpdateUserPassword のように -- :exec を指定したUPDATEクエリは、エラーのみを返します。

go
func UpdateUserPassword(ctx context.Context, userID int64, newPasswordHash string) error {
arg := db.UpdateUserPasswordParams{ // パラメータが複数の場合は構造体が生成される
ID: userID,
PasswordHash: newPasswordHash,
}
err := queries.UpdateUserPassword(ctx, arg)
if err != nil {
return fmt.Errorf("failed to update password for user %d: %w", userID, err)
}
// ユーザーが存在しなかった場合でもエラーにならない(影響行数が0になるだけ)
// 影響行数を確認したい場合は -- :execrows を使う
return nil
}

DELETE

DeleteUser のように -- :execrows を指定したDELETEクエリは、影響を受けた行数 (int64) とエラーを返します。

go
func DeleteUser(ctx context.Context, userID int64) (int64, error) {
rowsAffected, err := queries.DeleteUser(ctx, userID)
if err != nil {
return 0, fmt.Errorf("failed to delete user %d: %w", userID, err)
}
return rowsAffected, nil
}

4. トランザクションの扱い方

データベース操作において、複数のクエリをアトミックに実行する必要がある場合はトランザクションを使用します。sqlcは、Queries オブジェクトが DBTX インターフェースを受け取るように生成されるため、*sql.DB だけでなく *sql.Tx も受け取ることができます。

トランザクション内でsqlcの生成コードを使用するには、以下の手順で行います。

  1. db.BeginTx を呼び出してトランザクションを開始し、*sql.Tx オブジェクトを取得します。
  2. db.New() 関数に *sql.Tx オブジェクトを渡して、トランザクション用の新しい Queries オブジェクトを作成します。
  3. このトランザクション用の Queries オブジェクトを使って、トランザクションに含めたいクエリを実行します。
  4. すべてのクエリが成功したら tx.Commit() を呼び出します。途中でエラーが発生した場合は tx.Rollback() を呼び出します。

“`go
func CreateOrderWithItemsTx(ctx context.Context, userID int64, productIDs []int64) error {
// 1. トランザクションを開始
tx, err := db.BeginTx(ctx, nil) // nil はデフォルトのトランザクションオプション
if err != nil {
return fmt.Errorf(“failed to begin transaction: %w”, err)
}
// defer でロールバックを呼び出すことで、関数のどこでエラーが発生しても確実にロールバックされるようにする
// commit が成功した場合は nil を代入してロールバックを防ぐ
defer func() {
if p := recover(); p != nil {
// パニックが発生した場合もロールバック
tx.Rollback()
panic(p) // パニックを再発生させる
} else if err != nil {
// 通常のエラーの場合ロールバック
tx.Rollback()
}
}()

// 2. トランザクション用の Queries オブジェクトを作成
// これが sqlc が提供するトランザクションを扱うための主要な方法
qtx := queries.WithTx(tx) // Queries struct に自動生成される WithTx メソッド

// 3. トランザクション内でクエリを実行
totalAmount := float64(0)
productDetails := make(map[int64]db.Product)

// まず商品詳細を取得(トランザクション外で取得しても良いが、ロックが必要ならトランザクション内)
// sqlc で単一または複数の商品をIDで取得するクエリを定義・生成しておく必要がある
// 例えば GetProductById(ctx context.Context, id int64) (Product, error) のようなクエリを products.sql に定義

// ダミーの価格計算(実際はDBから取得する)
for _, prodID := range productIDs {
    // prod, err := qtx.GetProductById(ctx, prodID) // 例: 生成されたクエリ関数を呼び出す
    // if err != nil { ... return err }
    // totalAmount += float64(prod.Price) // 実際の価格を使用
    totalAmount += 100.0 // 仮の価格
}

// Orders テーブルに注文を作成
orderArg := db.CreateOrderParams{
    UserID:      userID,
    TotalAmount: fmt.Sprintf("%.2f", totalAmount),
}
order, err := qtx.CreateOrder(ctx, orderArg)
if err != nil {
    // defer でロールバックされる
    err = fmt.Errorf("failed to create order: %w", err)
    return err
}

// Order Items テーブルに商品明細を作成
for _, prodID := range productIDs {
    orderItemArg := db.CreateOrderItemParams{
        OrderID:   order.ID,
        ProductID: prodID,
        Quantity:  1, // 仮の数量
        // UnitPrice: fmt.Sprintf("%.2f", productDetails[prodID].Price), // 実際の価格を使用
        UnitPrice: "100.00", // 仮の価格
    }
    _, err = qtx.CreateOrderItem(ctx, orderItemArg)
    if err != nil {
        // defer でロールバックされる
        err = fmt.Errorf("failed to create order item for product %d: %w", prodID, err)
        return err
    }
}

// 4. すべて成功したらコミット
err = tx.Commit()
if err != nil {
    // コミットエラーもロールバック対象
    err = fmt.Errorf("failed to commit transaction: %w", err)
    return err
}

// コミット成功時は defer のロールバックが実行されないように err を nil にする (または別のフラグ管理)
// defer 内の err は名前付き戻り値や、関数スコープの外の変数を使う必要がある
// あるいは、以下のように defer の前に err = nil を代入する
// err = nil // この行を追加

return nil

}
“`

または、Queries struct に自動生成される WithTx メソッドを使用する方が、よりidiomaticで推奨される方法です。WithTx は、指定された *sql.Tx をラップした新しい Queries オブジェクトを返します。

“`go
func CreateOrderWithItemsTx(ctx context.Context, userID int64, productIDs []int64) (err error) {
// 1. トランザクションを開始
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf(“failed to begin transaction: %w”, err)
}

// 2. defer でロールバックを設定
// err という名前付き戻り値を使うことで、defer 実行時の err の値にアクセスできる
defer func() {
    if p := recover(); p != nil {
        // パニック時はロールバックしてパニックを再発生
        _ = tx.Rollback() // エラーを無視
        panic(p)
    } else if err != nil {
        // err が非nilならロールバック
        _ = tx.Rollback() // エラーを無視
    } else {
        // err がnilならコミット
        err = tx.Commit() // コミットエラーを err に代入
    }
}()

// 3. トランザクション用の Queries オブジェクトを作成
qtx := queries.WithTx(tx)

// 4. トランザクション内でクエリを実行 (qtx を使う)
totalAmount := float64(0)
// ... 商品価格計算 ...
totalAmount = 500.0 // 仮の合計金額

orderArg := db.CreateOrderParams{
    UserID:      userID,
    TotalAmount: fmt.Sprintf("%.2f", totalAmount),
}
order, err := qtx.CreateOrder(ctx, orderArg) // qtx を使う
if err != nil {
    // defer でロールバックされる
    err = fmt.Errorf("failed to create order in tx: %w", err)
    return err // 名前付き戻り値 err にエラーをセット
}

for _, prodID := range productIDs {
    orderItemArg := db.CreateOrderItemParams{
        OrderID:   order.ID,
        ProductID: prodID,
        Quantity:  1,
        UnitPrice: "100.00", // 仮の価格
    }
    _, err = qtx.CreateOrderItem(ctx, orderItemArg) // qtx を使う
    if err != nil {
        // defer でロールバックされる
        err = fmt.Errorf("failed to create order item for product %d in tx: %w", prodID, err)
        return err // 名前付き戻り値 err にエラーをセット
    }
}

// 5. エラーが発生しなかった場合、defer で tx.Commit() が実行される

return nil // err は nil のまま、または defer で tx.Commit() の結果が代入される

}
“`

この WithTx を使う方法は、トランザクションスコープ内でクエリを実行する際に、元の Queries オブジェクトを汚染しない(コネクションプール全体を使う Queries オブジェクトとトランザクション専用の Queries オブジェクトを使い分ける)という点で優れています。deferを使ったエラーハンドリングとコミット/ロールバックのパターンは、Goでトランザクションを扱う際の一般的なイディオムです。

5. エラーハンドリング

sqlcが生成するクエリ関数は、database/sql パッケージ(または指定したドライバ)から返されるエラーをそのまま返します。特に重要なのは、結果が見つからなかった場合に返される sql.ErrNoRows エラーです。これを使って、レコードが見つからなかったケースと、その他のデータベースエラーを区別することができます。

また、データベース固有のエラー(例: PostgreSQLのPQエラー)を詳細に扱いたい場合は、元のドライバのエラー型に型アサーションしたり、エラーラップ(fmt.Errorf("...", err))を使ってエラーの原因を辿れるようにしたりすることが重要です。

“`go
import (
“context”
“database/sql”
“errors”
“fmt”
// … その他のインポート
“my-awesome-app/db”
“github.com/lib/pq” // PostgreSQL ドライバのインポート
)

func GetUserByID(ctx context.Context, userID int64) (*db.User, error) {
user, err := queries.GetUserById(ctx, userID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// レコードが見つからなかった場合
return nil, nil // または custom package error, e.g., errors.New(“user not found”)
}
// その他のデータベースエラー
return nil, fmt.Errorf(“db: failed to get user by ID %d: %w”, userID, err)
}
return &user, nil
}

func CreateNewUserWithHandling(ctx context.Context, username, email, passwordHash string) (db.User, error) {
arg := db.CreateUserParams{
Username: username,
Email: email,
PasswordHash: passwordHash,
}
user, err := queries.CreateUser(ctx, arg)
if err != nil {
// PostgreSQL の一意性制約違反エラー (error code 23505) を検出する例
var pqErr
pq.Error
if errors.As(err, &pqErr) {
if pqErr.Code == “23505” {
// 一意性制約違反の場合
return nil, fmt.Errorf(“db: username or email already exists: %w”, err)
}
}
// その他のデータベースエラー
return nil, fmt.Errorf(“db: failed to create user: %w”, err)
}
return &user, nil
}
“`

エラーハンドリングはアプリケーションの堅牢性にとって非常に重要です。具体的なエラーコードや型は、使用しているデータベースとドライバによって異なります。

高度なトピック

sqlcには、基本的なCRUD操作に加えて、より高度なニーズに対応するための機能がいくつか用意されています。

1. カスタム型マッピング (go.overrides)

前述しましたが、データベースの特定の型をGoの標準型以外のカスタム型にマッピングしたい場合に sqlc.yamlgo.overrides 設定を使用します。これは、例えばUUID、JSON、ENUM、空間データ型などを扱う際に特に便利です。

“`yaml

sqlc.yaml (カスタム型マッピングの例)

version: “2”
packages:
– name: “db”
path: “./db”
queries: “./db/queries/”
schema: “./db/schema/”
engine: “postgresql”
go:
sql_package: “pgx/v5” # pgx/v5 を使うと pgtype を利用しやすい
overrides:
# PostgreSQL の UUID 型を gofrs/uuid.UUID にマッピング
– db_type: “pg_catalog.uuid”
go_type:
import: “github.com/gofrs/uuid”
package: “uuid”
type: “UUID”
nullable: false
# PostgreSQL の JSONB 型をカスタム構造体 (例: Config struct) にマッピング
– db_type: “pg_catalog.jsonb”
go_type:
import: “my-awesome-app/internal/types” # カスタム型を定義したパッケージ
package: “types”
type: “Config”
nullable: true # NULLABLE な JSONB カラムの場合
“`

カスタム型を使用する場合、その型が database/sql.Scannerdatabase/sql.Valuer インターフェースを実装している必要があります。これにより、SQLからGoへのスキャンと、GoからSQLへの値の変換が適切に行われます。pgtype パッケージの多くの型はこれらのインターフェースを実装しています。カスタム構造体をマッピングする場合も、これらのインターフェースを実装するか、Goの構造体とJSONB/JSON文字列間のマーシャリング/アンマーシャリングを適切に行う必要があります。

2. Hooks (sqlc.yamlhooks)

sqlcのコード生成プロセスにカスタムスクリプトを実行させたい場合にhooksを使用します。例えば、生成されたSQLファイルをフォーマットする、追加のリンティングを行う、生成されたGoコードに対して goimports を実行するなど、様々な用途に利用できます。

“`yaml

sqlc.yaml (hooks 例)

version: “2”
packages:
– name: “db”
path: “./db”
queries: “./db/queries/”
schema: “./db/schema/”
engine: “postgresql”
hooks:
# generate コマンド実行直後に実行されるフック
# 生成された Go コードに対して goimports を実行してフォーマットとインポート整理を行う
– command: “goimports -w .” # . は sqlc.yaml があるディレクトリを指すことが多い
output: “.” # フックの実行ディレクトリ
“`

hooksは強力ですが、依存関係や実行環境に注意が必要です。

3. ENUM型の扱い方

PostgreSQLのようなデータベースではENUM型を定義できます。sqlcはスキーマファイルで定義されたENUM型を検出し、対応するGoの型(通常は string)と、そのENUMで定義された値に対応する定数を生成します。

“`sql
— db/schema/schema.sql
CREATE TYPE user_status AS ENUM (‘active’, ‘inactive’, ‘suspended’);

CREATE TABLE users (
— … 他のカラム …
status user_status NOT NULL DEFAULT ‘active’
);
“`

これをsqlcで生成すると、models.go に以下のようなコードが生成されます。

“`go
// db/models.go (ENUM 生成例)

package db

import “fmt”

// UserStatus represents the user_status ENUM type.
type UserStatus string

const (
UserStatusActive UserStatus = “active”
UserStatusInactive UserStatus = “inactive”
UserStatusSuspended UserStatus = “suspended”
)

func (e UserStatus) ToPointer() *UserStatus {
return &e
}

func (e UserStatus) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
e = UserStatus(s)
case string:
*e = UserStatus(s)
default:
return fmt.Errorf(“unsupported scan type for UserStatus: %T”, src)
}
return nil
}

type NullUserStatus struct {
UserStatus UserStatus
Valid bool // Valid is true if UserStatus is not NULL
}

// Scan implements the Scanner interface.
func (ns *NullUserStatus) Scan(value interface{}) error {
if value == nil {
ns.UserStatus, ns.Valid = “”, false
return nil
}
ns.Valid = true
return ns.UserStatus.Scan(value)
}

// Value implements the driver Valuer interface.
func (ns NullUserStatus) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.UserStatus), nil
}

// … User 構造体 …
type User struct {
// … 他のフィールド …
Status UserStatus json:"status"
}
“`

生成された UserStatus 型は string のエイリアスであり、定義された値に対応する定数も提供されます。NULLABLEなENUMカラムの場合は NullUserStatus のようなヘルパー型も生成されます。これにより、GoコードでENUM値を型安全に扱えるようになります。

4. 名前付きパラメータ (: プレフィックス)

デフォルトでは、sqlcはSQLクエリのパラメータに $1, $2, … のような番号付きプレースホルダを使用することを想定しています。しかし、sqlc.yamlpackages.go.sql_package 設定で pgx/v5 など特定のドライバを指定した場合や、特定のデータベースエンジンでは、:name のような名前付きパラメータを使用できます。

“`sql
— db/queries/user.sql (名前付きパラメータ例)

— name: GetUserByUsername :one
SELECT id, username, email, password_hash, created_at
FROM users
WHERE username = :username LIMIT 1; — 名前付きパラメータを使用
“`

“`yaml

sqlc.yaml (名前付きパラメータを有効にする設定例 – pgx/v5 の場合)

version: “2”
packages:
– name: “db”
path: “./db”
queries: “./db/queries/”
schema: “./db/schema/”
engine: “postgresql”
go:
sql_package: “pgx/v5” # 名前付きパラメータはドライバに依存
# emit_params_in_struct: true # 名前付きパラメータの場合はデフォルトで true になる可能性あり
“`

名前付きパラメータを使用する場合、sqlcは対応するGo関数の引数として、パラメータ名に対応するフィールドを持つ構造体を生成することが多いです。可読性が向上するため、複数のパラメータを持つクエリで名前付きパラメータを使うことは有効な選択肢です。

5. Batch クエリ (-- :batch)

sqlcはバッチクエリの生成もサポートしています。これは、同じSQLクエリを異なるパラメータで複数回実行したい場合に、一度のデータベース往復で効率的に実行するための機能です。

“`sql
— db/queries/product.sql

— name: InsertProductBatch :batch
INSERT INTO products (name, description, price)
VALUES ($1, $2, $3);
“`

このクエリに対して -- :batch を指定してコードを生成すると、以下のような関数が生成されます。

“`go
// db/product.sql.go (Batch クエリ生成例)

type InsertProductBatchBatchResults struct {
br pgx.BatchResults // or sql.Result for database/sql
}

// InsertProductBatch creates a new batch to insert products.
func (q Queries) InsertProductBatch(ctx context.Context, arg []InsertProductBatchParams) InsertProductBatchBatchResults {
// … バッチを作成し、引数 arg の各要素でクエリを追加するコード …
batch := &pgx.Batch{} // or appropriate batch type for database/sql
for _, a := range arg {
batch.Queue(INSERT INTO products (name, description, price) VALUES ($1, $2, $3), a.Name, a.Description, a.Price)
}
// … バッチを実行 …
br := q.db.(interface { ExecBatch(context.Context, *pgx.Batch) pgx.BatchResults }).ExecBatch(ctx, batch) // ドライバに依存する呼び出し
// …
return &InsertProductBatchBatchResults{br: br}
}

// Exec calls Exec on the underlying BatchResults.
func (b *InsertProductBatchBatchResults) Exec(f func(int, error)) {
// … バッチの結果を処理するコード …
for i := 0; ; i++ {
_, err := b.br.Exec()
if err != nil {
if err == pgx.ErrNoRows { // or sql.ErrNoRows etc.
break
}
f(i, err)
return
}
f(i, nil) // または影響行数などを渡す
}
}
“`

生成されるバッチ関連のコードは、使用する sql_package (特に pgx/v5) によって大きく異なります。pgx/v5 を使うと pgx.Batch を利用した効率的なバッチ処理コードが生成されます。database/sql の場合はドライバのバッチ対応に依存します。

バッチクエリは、多数の行をまとめて挿入・更新・削除したい場合に非常に高いパフォーマンスを発揮します。

6. マイグレーションツールとの連携

sqlcはスキーマ定義をSQLファイルから読み取ります。データベーススキーマの変更は、アプリケーション開発において避けられません。スキーマの変更を管理するために、Flyway, migrate, Goose といったデータベースマイグレーションツールを利用するのが一般的です。

これらのツールは、CREATE TABLE, ALTER TABLE などのスキーマ変更SQLファイルをバージョン管理し、適用履歴を管理します。sqlcは、これらのマイグレーションツールが管理しているスキーマファイル(通常はバージョン番号付きのファイル)を sqlc.yamlschema 設定で指定することで連携できます。

例: Flyway を使用している場合

my-awesome-app/
├── go.mod
├── main.go
├── db/
│ ├── sqlc/
│ │ └── sqlc.yaml
│ └── queries/
│ └── user.sql
│ └── migration/ # Flyway migration scripts directory
│ ├── V1__create_users_table.sql
│ ├── V2__add_products_table.sql
│ └── ...
└── ...

sqlc.yaml:

yaml
version: "2"
packages:
- name: "db"
path: "./db"
queries: "./db/queries/"
schema: "./db/migration/" # マイグレーションスクリプトディレクトリを指定
engine: "postgresql"
# ... その他設定 ...

sqlc generate を実行する際は、マイグレーションツールで最新のスキーマがデータベースに適用されている状態をシミュレートできるようなSQLファイルを schema ディレクトリに用意しておくか、あるいはすべてのマイグレーションファイルを指定する必要があります。多くのマイグレーションツールは、現在のスキーマ全体をダンプする機能を持っています。そのダンプファイルまたはすべてのマイグレーションファイルを順に読み込ませることで、sqlcは最新のスキーマ定義を把握できます。

マイグレーションの実行と sqlc generate の実行は、開発ワークフローの中で密接に関連します。スキーマを変更する際は、マイグレーションファイルを作成・実行し、その後すぐに sqlc generate を実行して生成コードを更新する、という流れになります。

7. テスト戦略

sqlcを使ったデータベースアクセスのテストは、主に以下の2つのレベルで行えます。

  • 単体テスト (Unit Tests): db.Querier インターフェースをモック実装することで、データベースに実際に接続することなく、アプリケーションロジック(データベース呼び出しを行うサービス層など)の単体テストを行うことができます。sqlcがインターフェースを生成してくれる(emit_interface: true)のはこのためです。
  • 結合テスト (Integration Tests): 実際のデータベースインスタンスに対してテストを行います。テスト実行前にデータベースをクリーンな状態に戻し、必要なスキーマと初期データをセットアップします。sqlcが生成したクエリ関数を直接呼び出し、期待通りの結果が得られるか、エラーハンドリングが正しく行われるかなどを検証します。この場合、テスト用のデータベースインスタンスをDockerなどで起動するのが一般的です。

sqlcで生成されたクエリ関数自体(Queries struct のメソッド)をテストする場合も、実際のデータベースに対して結合テストを行うのが現実的です。テスト用のデータベースに接続し、テスト対象のクエリを実行し、結果を検証します。

sqlcのメリット・デメリットまとめ

これまでの説明を踏まえ、sqlcのメリットとデメリットを改めて整理します。

メリット:

  • 型安全: コンパイル時にデータベースアクセス関連の多くのエラーを検出できるため、信頼性の高いコードを書けます。
  • 開発効率: 定型的なマッピングコードやパラメータバインディングコードの手書きが不要になり、開発速度が向上します。
  • メンテナンス性: スキーマやクエリの変更に対して、コード生成によって容易に対応できます。変更漏れや手作業によるミスを減らせます。
  • パフォーマンス: 実行時のオーバーヘッドが少なく、開発者が書いたSQLがそのまま実行されるため、パフォーマンスチューニングがしやすいです。
  • 可読性: SQLクエリが独立したファイルに分離されるため、GoコードとSQLのそれぞれが読みやすくなります。
  • SQLインジェクション対策: 生成コードは常に安全なプレースホルダを使用します。
  • 既存SQL資産の活用: 既に存在するSQLクエリやデータベーススキーマをそのまま活用できます。

デメリット:

  • SQLファイルの管理: Goコードとは別にSQLファイルを管理する必要があります。Goコード内にSQLを書きたい開発者にとっては手間と感じるかもしれません。
  • 学習コスト: sqlc.yaml の設定方法や、sqlc固有のコメント、ワークフローなどを学ぶ必要があります。
  • 生成コードへの依存: 生成されたコードに依存するため、生成コードの内部挙動を理解する必要が出てくる場合があります。
  • 動的クエリの制限: 実行時に複雑な条件でSQL構造が変化するような完全に動的なクエリには向いていません。ただし、一部の動的な部分はGoコードで補完したり、複数のクエリを定義して使い分けたりすることで対応可能です。
  • 初期設定の手間: ORマッパーと比較すると、初期のセットアップ(sqlc.yaml, スキーマファイル作成など)に多少手間がかかる場合があります。

これらのメリット・デメリットを考慮すると、sqlcは特に以下のようなプロジェクトに適していると言えます。

  • 複雑なSQLクエリやデータベース固有の機能を積極的に活用したいプロジェクト。
  • パフォーマンスが重要な要件であるプロジェクト。
  • 既存のデータベーススキーマやSQL資産があるプロジェクト。
  • SQLとGoコードの役割を明確に分離したいチーム。
  • 型安全性を重視し、コンパイル時エラーで問題を早期に発見したいプロジェクト。

まとめと展望

この記事では、sqlcを使った型安全なGo製データベースアプリケーション開発について、導入からセットアップ、SQLファイルの作成、コード生成、生成コードの活用、そして高度なトピックやテスト戦略、メリット・デメリットまで詳細に解説しました。

sqlcは、SQLをそのまま利用できるという「SQLファースト」のアプローチを取りつつ、コード生成によってGo言語におけるデータベースアクセスの課題(型安全性、開発効率、メンテナンス性)を効果的に解決する強力なツールです。ORマッパーとは異なり、SQLの抽象化は行わず、GoとSQLの間の安全な橋渡し役として機能します。

型安全なクエリ関数、スキーマに基づくGo構造体の自動生成、パラメータの安全なバインディングといったsqlcの機能は、開発者が煩雑な手作業から解放され、アプリケーションのビジネスロジック開発に集中できるよう支援します。また、トランザクションの扱いやすさや、emit_interface によるテスト容易性の向上も大きな利点です。

一方で、SQLファイルを適切に管理し、sqlc.yaml の設定を理解する必要があるという学習コストや、完全に動的なクエリには向かないという制限もあります。しかし、ほとんどのリレーショナルデータベース連携において、sqlcが提供するメリットはデメリットを上回ることが多いでしょう。

今後もsqlcは進化を続けると思われます。対応データベースの追加、コード生成オプションの拡充、パフォーマンスの改善などが期待されます。Go言語でリレーショナルデータベースを扱う開発において、sqlcはますます重要なツールとなっていくはずです。

もしあなたがGoでデータベースアプリケーションを開発しており、手書きのSQLマッピングや実行時エラーに悩まされているなら、ぜひsqlcを試してみてください。きっと、あなたの開発体験をより安全で、効率的で、楽しいものに変えてくれるでしょう。

Happy Coding with sqlc!

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール