💪

sqlc + MySQL で集計関数を使用した時の返り値の型を正しくキャストする

2023/12/23に公開

この記事は GENDA Advent Calendar 2023 の24日目の記事です。
https://qiita.com/advent-calendar/2023/genda

TL;DR

sqlcは、SQLのクエリから型安全なインターフェースを持つコードを生成するライブラリーである。
v1.24.0 現在、MySQLの集計関数を使用したクエリにおいては、型の指定が正しく行われなかった。
生成されたメソッドから出力される値は簡単な型アサーションでは正しくキャストできないので型スイッチなどで正しくハンドリングしてキャストする必要がある。

sqlcとは

https://github.com/sqlc-dev/sqlc
sqlcはSQLクエリの静的解析と型安全なインターフェースを持つコードの生成を行うオープンソースのライブラリーで、2020年の2月にv1.0.0 がリリースされて以来、多くのGitHub Starを集めている人気ツールの1つです。

その特徴は、ひとたびsqlcの設定ファイルとDBのスキーマ、そして処理に必要なSQLのクエリを用意してしまえば、sqlc generateコマンド1つで型安全なGoのアプリケーションコードを自動で生成してくれることです。
sqlcを使うことで、

  • モデルとなる構造体の実装
  • 複数テーブルをJOINした際のマッパー実装
  • 定型的なSQLクエリコードの実装

などが不要になります。
現在Go以外の言語への対応も続々と進んでいるようです。対応している言語とDBエンジンの組み合わせや詳しい使い方は公式ドキュメントを参照してください。

MySQLの集計関数を使用した時のsqlcの挙動

GoとMySQLを用いたAPIサーバーのDB操作の部分をsqlcを用いて自動生成しようと進めていたところ、集計関数を用いた場合に、生成されたメソッドにおいて、集計後の値の型の指定が正しく行われていないことが判明しました。

実験

sqlcのplaygroundを利用して、今回発生した状況の再現実験をしてみました。(実験の詳細はこちら

まず、簡単なsalesテーブルを以下のように定義します。

CREATE TABLE sales (
  id bigint NOT NULL,
  user_id bigint NOT NULL,
  amount integer NOT NULL,
  PRIMARY KEY (id)
);

そして、あるユーザーに紐づいたsalesを集計したい場合を考えます。以下がそのクエリです。

-- name: TotalAmount :one
SELECT sum(amount) as tot
FROM sales
Where user_id = ?;

sqlcによって生成されるコードのメソッド部分は以下のようになります。

func (q *Queries) TotalAmount(ctx context.Context, userID int64) (interface{}, error) {
	row := q.db.QueryRowContext(ctx, totalAmount, userID)
	var tot interface{}
	err := row.Scan(&tot)
	return tot, err
}

集計後の値を格納する変数であるtotの型がinterface{}となっており、正しく指定されていませんでした。

PostgreSQLの場合だと、集計後の値の型を正しく指定してコードを生成してくれます。

func (q *Queries) TotalAmount(ctx context.Context, userID int64) (int64, error) {
	row := q.db.QueryRowContext(ctx, totalAmount, userID)
	var tot int64
	err := row.Scan(&tot)
	return tot, err
}

この問題は、現状未解決のissueとなっているようです。

型アサーションでの対応

型アサーションでint64型にキャストすればいいのではないかと考えたのですが、うまくいかず。

// これでは正しい値が取り出せない.
intTot, ok := tot.(int64)

int64型が具象型として入っているわけではなさそうでした。

解決

問題の原因は、Goのdatabase/sqlパッケージのRowsのScanメソッドの仕様によるものでした。Scanメソッドはinterface{}型を受け取ると、driverから読み出された値をそのままコピーするだけな仕様になっているようです。

If an argument has type *interface{}, Scan copies the value provided by the underlying driver without conversion.

したがってtotに入っている具象型は[]byte型となっていたようです。

// これで正しい値が取り出せた.
intTot, err := strconv.ParseInt(string(tot), 10, 64)

まとめ

MySQLで集計関数を用いたクエリを用意した場合、sqlcによって生成されるコードの集計後の値の型がinterface{}型で指定されてしまいます。database/sqlパッケージのRowsのScanメソッドの仕様によって、型アサーションのみでは想定する具象型の値を取り出すことができず、正しく型をキャストする必要がありました。

最後に

https://github.com/sqlc-dev/sqlc/pull/2555

以前に、ドキュメントの修正という簡単なものですがこの素晴らしいsqlcに貢献できたので、今後も貢献できたらいいなと思っています。今回の問題の解決にもトライしてみようかと思っています。

謝辞

  • 7kaji-san 
    GENDAのインターンでメンターをしてくださっていて、アドベントカレンダーに参加するにあたってこの記事もレビューいただきました。ありがとうございます!今回のこの事象も一緒に解決してくれました。
GENDA

Discussion