はじめに
Technogoly Innovation Group 辻です。Go には Gorm や SQLBoiler をはじめとして様々な O/Rマッパ があります。2021 年には当社のブログで OR マッパーの連載を行ったこともありました。絶対的な O/Rマッパ があるわけではなく、業務システムの特性やチーム構成などに合わせて O/Rマッパ を選択することになるでしょう。
今回、私たちのチームでは、バッチ処理が中心的な業務システム開発において Go の O/Rマッパ に sqlc を採用しました。素の SQL を書いていくチームの開発方針1とマッチし、開発体験は非常に良かったです。一方、枯れきってはいない O/Rマッパ ではあります。いくつか想定外の挙動が発生し GitHub の Issue を見ながら問題を切り分けることもありました。
これから sqlc を導入してみようかな、と考えている方々の参考になればと思い、sqlc を業務システム開発に導入してみて良かった点とハマった点を紹介します。
なお本記事では sqlc の概要は扱いません。そもそも sqlc って何? って方は sqlc の作者が書いた記事や OR マッパー連載で紹介したこちらの記事を見てみると良いでしょう。
ソフトウェアのバージョンは以下です。データベースは PostgreSQL を採用していることに注意してください。
- PostgreSQL 13.7
- Go 1.19
- sqlc 1.15.0
良かった点
1. SQL の開発に集中できる
sqlc を使う一番のメリットは SQL の開発に集中できることです。SQL の実装以外の面倒なことは sqlc がやってくれます。以下の流れで開発できます。
- SQL を書く
sqlc generate
でコード生成する- 生成された API を Go のコードから呼び出す
sqlc を使うと、SQL から、SQL の結果をマッピングできる構造体やデータベースにアクセスできる API を生成できます。生成される構造体のフィールドの型は SQL の結果に応じて、適切に選択されます。また、バインドパラメータとして SQL に値を渡す場合も、パラメータを渡すための構造体とその構造体を API に渡せるコードが生成されます。アプリケーションではパラメータの構造体に値をセットするだけでよく、構造体のフィールドの型はコンパイル時にチェックできます。
クエリの結果をマッピングする Go の構造体を作成したり、バインドパラメータの順番やデータ型を注意深く確認したり、クエリを発行するボイラープレートコードを実装したりすることは必要はなくなります。
2. ファイル生成が高速である
sqlc はファイル生成が高速です。数十の SQL ファイルからコードを生成するのに 1 秒もかかりません2。高速な理由の 1 つは sqlc ではコード生成時にデータベースアクセスを必要としないためです3。SQL ファイルからクエリの結果をマッピングできるコード生成ライブラリは sqlc の他に xo/xo などがあります4。ただコードを生成するときにデータベースアクセスが必要になり、生成にオーバーヘッドがかかります。ファイル生成は何度も実行するため、その操作が高速であることは地味ながら非常に嬉しいポイントでした。
3. 自然なコードが生成できる
自然なコードが生成でき、sqlc が生成したコードを違和感なく利用できました。特に、パラメータに関して $1
, $2
などとしたときに、生成される Go のコードはテーブルのカラム名に応じたフィールド名となります。たとえば以下の users
テーブルにレコードを INSERT する SQL から生成されるコードは NewUser()
と NewUserParams
のようになります。
-- name: NewUser :exec |
ID
や FirstName
といったフィールド名を持つ構造体が生成されます。
type NewUserParams struct { |
また、必要に応じて SQL 上のパラメータを sqlc 固有の特殊な構文を使い、 @my_first_name
などと記述して、生成される Go のコードのフィールド名を書き換えられます5。$1
などのパラメータで生成できるフィールド名に違和感がある場合は @xxxx
を使って任意のフィールド名に書き換えられる柔軟さも良かったです。
- 補足事項
なお、チーム開発で導入する場合はあらかじめ命名規約を決めておいたほうが良いです。
API 名は SQL のコメントで記載した命名が使われます。上の例では -- name: NewUser
としている NewUser
が API 名や構造体名に使われます。NewUser
, CreateUser
あるいは InsertUser
などと選択する余地があり、開発者間で揺れるポイントです。
4. 生成できるコードの種類が豊富である
database/sql
パッケージの ExecContext()
や QueryContext()
、QueryRowContext()
を使って SQL を実行できるコードが生成できます。また PostgreSQL 専用のドライバーである pgx
の機能を使ったバッチ処理可能なコードも生成できます。さらに Copy
プロトコルを使った高速なデータコピーができるコードも生成できます。業務システム開発では 10 万~100 万といったオーダーのデータを扱うこともあったため、バッチ処理や Copy
プロトコルをサポートしていることは非常に嬉しいです。
生成するコードは sqlc
のクエリアノテーションで指定できます。詳しくは Query annotations などを参照してください。
5. 生成されるデータ型を柔軟に変更できる
生成されるコードのデータ型を柔軟に設定できる点も便利です。
たとえばドライバーに pgx
を使っているとき PostgreSQL の numeric
型のカラムはデフォルトでは pgtype.Numeric
型にマッピングされます。しかし誤差を許容して float64
型で扱いたいケースがありました6。
設定ファイルの overrides
機能を使うことで生成される型を変更できます。上記のケースであれば、以下のような記述を YAML の設定ファイルに記載します。
overrides: |
6. 複数の開発言語をサポートしている
2022/11 現時点ではまだベータ版ですが、実は sqlc は Go 以外にも Python や Kotlin といった言語をサポートしています7。「SQL を書いて、コード生成し、そのコードを呼び出す」という開発体験は言語を問わず、同じです。私たちのチームでは主に Go での開発がメインでした。ただ、いくつか小さなバッチ機能を Python で実装する必要があり、 sqlc を導入することでスムーズに開発できました。
sqlc の作者は protoc
のような多言語のコードを生成することを目的の 1 つとしている、と言っています。TypeScript や Rust などの言語も将来的にサポートされるかもしれません(#296)。
7. 1SQL ファイルに複数のクエリを書いても良い
1SQL ファイルに 1 クエリ、というイメージがあるかもしれませんが特に制約はありません。1SQL ファイルに複数のクエリを書いても良いです。たとえば、以下のように CRUD を 1SQL ファイルに集約しても、各 SQL に紐づく API が生成できます。
-- name: GetAll :many |
ハマった点
sqlc は素晴らしい O/Rマッパ ですが、開発中にいくつかハマりどころがありました。インパクトが大きかった順に記載します。
1. 有効な SQL にもかかわらず sqlc でコード生成できないことがある
基本的には PostgreSQL で実行できる SQL はすべて sqlc で扱えます。しかし、いくつかの SQL は PostgreSQL 上は有効にも関わらず、sqlc でコード生成に失敗します。コード生成できない場合はその SQL を sqlc では使わないように書き換えする、あるいは sqlc を経由せず database/sql
パッケージの API から直接 SQL を呼び出すなどの回避する必要があります。
参考までに sqlc でコード生成できない SQL の例をあげます。
- 例1:UPDATE 句で JOIN を使う場合(#1100)
このパターンが一番影響がありました。JOIN
と UPDATE
が同時にできないため、JOIN
して更新データを SELECT
するクエリと UPDATE
するクエリの 2 つに分割して対処しました。
UPDATE |
- 例2:サブクエリと
AS
,GROUP
句を使う場合(#1886)
以下のような SQL もコード生成に失敗します。
SELECT |
2. マッピングする構造体の型が NULL になりうる場合でも NULL を許容しない型で生成されることがある
テーブルのカラム自体は NOT NULL 制約があるが、SQL の条件によって NULL になりうるケースです(#1208)。このケースはコンパイル時はエラーにならず、実行時にカラムの値が NULL になるとエラーが発生するため、注意が必要です。
たとえば以下の one
テーブルの name
カラムは NOT NULL 制約が付与されています。しかしサブクエリの WHERE 句の条件に該当しない場合は SELECT できる結果が NULL になりえます。このような条件下でも sqlc は NOT NULL なカラムとして構造体を生成します。期待としては sql.NullInt32
型として NULL を許容される型のフィールドを想定しますが、実際には int32
と NULL な値をマッピングできない型のフィールドになります。
CREATE TABLE one ( |
SELECT |
type TestRow struct { |
3. IN 句に複数の値を渡せない
IN
句で複数の値を渡して、複数のレコードを抽出するクエリは一般的ですが sqlc を使ってコード生成する場合は技巧が必要になります(#77, #167)。たとえば authors
テーブルからいくつかの id
にマッチするレコードを抽出するクエリを考えてみます。
-- name: ListAuthorsByIDs :many |
このようなクエリから生成される Go のコードでは string
の単一の値は渡せますが複数の ID を含んだ []string
の値は渡せません。生成されるメソッドのシグネチャは以下になります。
ListAuthorsByIDs(ctx context.Context, id string) ([]Author, error) |
対処法法として、クエリを以下のように ANY
句8を使うように書き換える必要があります。
SELECT * FROM authors |
上記のような SQL からは以下のシグネチャのコードが生成でき、また複数の値にマッチするレコードを取得できます。
ListAuthorsByIDs(ctx context.Context, ids []string) ([]Author, error) |
4. 集計関数の結果や CASE 句の値が interface{} 型になる
MIN()
や MAX()
, SUM()
といった集計関数の結果は、引数のデータ型ではなく interface{}
型として生成されます。
たとえば以下の MIN
を使うケースでは int32
型で生成されることを期待するかもしれませんが、interface{}
型になります(#1574)。
CREATE TABLE sales ( |
こうしたケースでは明示的に SQL で型キャストを行うことで回避できます。上記のケースであれば ::INTEGER
あるいは CAST(MIN(vat) AS INTEGER)
と明示することで Go では int32
型にマッピングできます。
SELECT |
CASE 句を扱う場合も同様に interface{}
型となるため、必要に応じて型キャストをすると良いでしょう。
5. DDL が扱えない
sqlc は 1.15.0 では DDL をサポートしていません(#1661)。複雑なビジネスロジックを SQL で実装しているときに、処理の中間結果を一時表に保持したいユースケースがありました。セッション開始時に CREATE TEMPORARY TABLE ...
として一時表を作成しようとしましたが sqlc ではコード生成できませんでした。これは pgx
の Exec()
から直接クエリを発行して対処しました。
まとめ
業務システム開発に sqlc を導入して良かった点とハマった点を紹介しました。素の SQL を書きたいケースでは非常に良い O/Rマッパ と感じています。Go の O/Rマッパ の選択に迷っている方の参考になれば嬉しいです。
- 1.業務システム開発ではロジックを SQL に寄せることも多く、複数のテーブルを JOIN したり、ウィンドウ関数を使って処理をしたりなど、SQL が複雑になりがちです。複雑な SQL は O/Rマッパ のクエリビルダーAPI を使ってクエリを組み立てるのではなく、手書きで実装したいケースが多いです。たとえば O/Rマッパ に Gorm を使っていたとしても Raw() を使わざるを得ない、あるいは使いたいケースが多くありました。 ↩
- 2.生成するコード量は 1 ファイルに含まれる SQL の数にもよるため、あくまで目安です。 ↩
- 3.データベースアクセスしないにも関わらず型安全なコードを生成できるのは理由は、sqlc が C 言語で書かれた本物の PostgreSQL のクエリパーサを Go から呼び出し、パースされたツリーからコード生成するアプローチを採用しているためです。 ↩
- 4.
xo
はファイル生成に時間がかかるだけでなく、生成できるコードが私たちの求める機能/非機能要件と合わなかったため、採用を見送りました。 ↩ - 5.https://docs.sqlc.dev/en/stable/howto/named_parameters.html?highlight=Named parameter#naming-parameters ↩
- 6.
pgtype.Numeric
型はbig.Int
型の値をフィールドに含んでおり、浮動小数点演算の誤差が許されない場合では、このデータ型を使うことは妥当です。一方、数値計算がfloat64
のような+
,-
,/
,*
でできません。API を使って演算しますが、コードが読みづらく、メンテナンスが難しくなる点がデメリットとしてあります。 ↩ - 7.https://docs.sqlc.dev/en/stable/reference/language-support.html?highlight=beta#database-and-language-support ↩
- 8.
ANY
句はSOME
句でもよいです。https://www.postgresql.jp/document/13/html/functions-comparisons.html ↩