Swiftでもsqlcを使ってみよう
sqlcとは
sqlcは、SQLのクエリに型をつけてくれるツールです。
アプリケーションがデータベースにアクセスする際は、ORMを使うか、DSLによるクエリビルダを使うか、文字列でSQLを書くかなどありますが、sqlcはそれに新たな選択肢を与えてくれます。
sqlcはコード生成ツールの一種で、クエリを解析してパラメータと結果の行を各言語の関数や型として生成することができます。
例えば、今回私が試した例では以下のSQLから次のようなSwiftコードを生成します。
-- name: GetArtistByID :one
SELECT ArtistId, Name
FROM artists
WHERE ArtistId = ?1
struct GetArtistByID: SqlcQueryOne {
static var sql: String {
"""
SELECT ArtistId, Name
FROM artists
WHERE ArtistId = ?1
"""
}
let sql: String
let binds: [SQLiteData]
init(id: Int) {
var builder = SqlcRawQueryBuilder(sql: Self.sql)
builder.bind(value: id, atParamIndex: 1)
(sql, binds) = builder.build()
}
struct Row: DecodableFromSQLiteRow, Sendable {
var artistid: Int
var name: String
static func decode(from row: SQLiteRow) throws -> Row {
let columns = row.columns
return Row(
artistid: try .decode(from: columns[0]),
name: try .decode(from: columns[1])
)
}
}
}
セットアップ
sqlcは現在Swiftには正式に対応していないため、今回はsqlc-gen-from-templateを利用します。テンプレートを用いた汎用的なコード生成プラグインを用いて、Swiftに対応させます。
- Installing sqlc: https://docs.sqlc.dev/en/latest/overview/install.html
- sqlc-gen-from-template: https://github.com/fdietze/sqlc-gen-from-template?tab=readme-ov-file#installation
- 私の場合は今回、
go install github.com/fdietze/sqlc-gen-from-template@latest
しました。
- 私の場合は今回、
設定ファイルの記述
リポジトリルートに、sqlcの設定ファイルと生成用のテンプレートを置きます。
version: "2"
plugins:
- name: sqlc-gen-from-template
process:
cmd: sqlc-gen-from-template # https://github.com/fdietze/sqlc-gen-from-template
sql:
- engine: "sqlite"
queries: "queries.sql"
schema: "schema.sql"
codegen:
- out: Sources/Gen/
plugin: sqlc-gen-from-template
options:
template: "query_template.swift.tmpl"
filename: "Queries.swift"
formatter_cmd: "swift format"
// Code generated by sqlc using sqlc-gen-from-template; DO NOT EDIT.
// Template: query_template.swift.tmpl
import Foundation
import SQLiteNIO
{{ define "SwiftType" -}}
{{- $t := .Type.Name -}}
{{- if or (eq $t "INTEGER") (eq $t "integer") -}}Int
{{- else if or (eq $t "REAL") (eq $t "real") -}}Double
{{- else if or (eq $t "TEXT") (eq $t "text") -}}String
{{- else if or (eq $t "BLOB") (eq $t "blob") -}}Data
{{- else -}}String
{{- end -}}
{{- if not .NotNull -}}?{{- end -}}
{{- end }}
enum Query {
{{- range .Queries }}
struct {{ .Name }}: {{ if eq .Cmd ":many" }}SqlcQueryMany{{ else if eq .Cmd ":one" }}SqlcQueryOne{{ else }}SqlcQueryExec{{ end }} {
static var sql: String { """
{{ .Text }}
""" }
let sql: String
let binds: [SQLiteData]
init(
{{- range $i, $p := .Params -}}
{{- if $i }}, {{ end -}}
{{ $p.Column.Name }}: {{ if $p.Column.IsSqlcSlice }}[{{ template "SwiftType" $p.Column }}]{{ else }}{{ template "SwiftType" $p.Column }}{{ end -}}
{{- end -}}
) {
var builder = SqlcRawQueryBuilder(sql: Self.sql)
{{- range $i, $p := .Params -}}
{{- if $p.Column.IsSqlcSlice }}
builder.bind(values: {{ $p.Column.Name }}, atSliceName: "{{ $p.Column.Name }}")
{{- else }}
builder.bind(value: {{ $p.Column.Name }}, atParamIndex: {{ $p.Number }})
{{- end }}
{{- end }}
(sql, binds) = builder.build()
}
{{- if or (eq .Cmd ":many") (eq .Cmd ":one") }}
struct Row: DecodableFromSQLiteRow, Sendable {
{{- range $i, $c := .Columns }}
var {{ $c.Name }}: {{ template "SwiftType" $c }}
{{- end }}
static func decode(from row: SQLiteRow) throws -> Row {
{{- if gt (len .Columns) 0 -}}
let columns = row.columns
{{- end }}
return Row(
{{- range $i, $c := .Columns -}}
{{- if $i }},{{ end }}
{{ $c.Name }}: try .decode(from: columns[{{ $i }}])
{{- end }}
)
}
}
{{- end }}
}
{{- end }}
}
今回はテンプレートをSQLiteNIO用に生成しています。
この辺は利用するデータベースドライバやORMによって柔軟に対応できるため、テンプレート形式のプラグインは便利だなと思いました。
スキーマとクエリを書く
同様にリポジトリルートにテーブルのスキーマとクエリ集を配置します。
CREATE TABLE artists (
ArtistId INTEGER PRIMARY KEY,
Name TEXT NOT NULL
);
-- name: GetArtistByID :one
SELECT ArtistId, Name
FROM artists
WHERE ArtistId = sqlc.arg(id);
コード生成する
ここまでセットアップが完了していた場合コード生成は簡単で、
sqlc generate
を実行するとクエリが分析されてコード生成されます。GetArtistByID
のコード生成結果は最初の章に貼ってあります。
スキーマを元に自動的にクエリの型が決定され、*
やJOINなどの複雑な要素もしっかり反映されていて非常に優秀なツールだと思いました。
利用側のコードは以下の形になります。
let artist = try await conn.execute(Query.GetArtistByID(id: 1))
print(artist?.name) // クエリ解析結果からnameプロパティが自動生成されている
そのほか、今回試したコードは以下に置いてあります。
難しかったところ
IN
句への対応が大変でした。sqlcにおいて可変長パラメータを埋め込みたい場合はクエリにsqlc.slice(track_ids)
などと記述するのですが、実際にこのパラメータをデータベースドライバに引き渡す際はその個数分のパラメータに展開する必要があり、いろいろな考慮が必要でした。
特に通常のパラメータはインデックスが付与(?2
など)されていてバインドする位置が固定されているため、固定長パラメータが使用していないパラメータインデックスを見つけて順番に配置していく、という考慮が複雑でした。
この苦労はこの辺に現れています。
他ツールとの比較
Swiftのサーバサイドアプリケーションにおいては、主にSQLKitを使ったエコシステムが中心となっています。
- SQLKit: メソッドチェーンによるDSLでSQLを組み立てる
- Fluent: モデルクラスを定義し、Swiftらしい命名のインターフェースから裏側でSQLを構築
- SQLKitをラップして実装されているため、一部SQLKitと互換性がある
- 手書き: SQLKitの
SQLQueryString
を使ってコード中でクエリを手書きする
これらの方法はそれぞれメリットとデメリットがあり、一概にどれが優れた方法か議論するのは難しいです。
ここに、新たな選択肢としてsqlcを加えて、私の思う各手法の特徴をまとめた表が以下になります。
ツール | 型安全性 | 柔軟性 | JOINの楽さ | 実行速度 |
---|---|---|---|---|
SQLKit | ×(使い方によっては⚪︎) | ⚪︎ | × | ⚪︎ |
Fluent | △(使い方によっては⚪︎) | × | ◎ | × |
コード中で手書き | × | ◎ | ⚪︎ | ⚪︎ |
sqlc | ◎ | ◎時々× | ⚪︎ | ◎ |
SQLKitとFluentはそれぞれ型安全とは言い難く、特にFluentはモデル型をクラスで取り扱う必要があるため値型中心のSwiftとは相性が悪くまたSwift Concurrencyにも適合できません。
ただし、SQLKitはテーブルスキーマを型安全に定義するやりようがあったり、Fluentもモデル型を毎回値型に変換するなど運用でカバーできるため、ある程度は安全性を担保できます。
Fluentはリフレクションなど動的に動く部分が多いため、実行速度の面で明確に劣っています。
特に親子関係のあるモデルを取り出す、いわゆるeager load的な操作はクエリが常に分割され遅いです(ActiveRecordならJOINで済ませてくれそうな場面でも毎回分割してクエリする)。ただし、非常に簡単な記述で親子関係のあるモデルを取り出せるため、使い勝手の面は非常に良いです。
sqlcの変わった特徴としてデコードの速度が挙げられます。現状SwiftがDBから返ってきた行をデコードする際はDecodable
プロトコルに依存した実装がデファクトとなっているのですが、これはいくつか問題を抱えており少々遅いです。
sqlcの場合はデコードのための実装が静的に定義できるため高速だと考えられます。ただし仕組みから私がそう思っているだけで、測っていません。
一方で最大の欠点として、動的なクエリが挙げられます。
アプリケーションによっては複雑なフィルタ機能などによって多数のWHERE
を実行時に繋ぎ合わせることがありますが、あらかじめクエリを宣言しておく必要のあるsqlcではそれができません。
組み合わせの個数分だけクエリを定義しておく必要があり、現実的ではありません。
また、クエリの再利用もできません。同じクエリをサブクエリとして使ったりするケースでは、毎回クエリをコピペするか、データベース側の機能を使ってVIEW
などを用意する必要があります。
まとめ
SQLは難しいのでなるべく色々なエラーを静的に検知したいけど、各手法に明確にメリットとデメリットがあるのでどのツールを使うべきなのか悩ましい。
うまく組み合わせて面倒なくいいとこ取りできるといいな。
Discussion