🦭

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に対応させます。

設定ファイルの記述

リポジトリルートに、sqlcの設定ファイルと生成用のテンプレートを置きます。

sqlc.yml
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"
query_template.swift.tmpl
// 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によって柔軟に対応できるため、テンプレート形式のプラグインは便利だなと思いました。

スキーマとクエリを書く

同様にリポジトリルートにテーブルのスキーマとクエリ集を配置します。

schema.sql
CREATE TABLE artists (
  ArtistId   INTEGER PRIMARY KEY,
  Name       TEXT NOT NULL
);
queries.sql
-- 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プロパティが自動生成されている

そのほか、今回試したコードは以下に置いてあります。

https://github.com/sidepelican/SwiftSqlcPractice

難しかったところ

IN句への対応が大変でした。sqlcにおいて可変長パラメータを埋め込みたい場合はクエリにsqlc.slice(track_ids)などと記述するのですが、実際にこのパラメータをデータベースドライバに引き渡す際はその個数分のパラメータに展開する必要があり、いろいろな考慮が必要でした。

特に通常のパラメータはインデックスが付与(?2など)されていてバインドする位置が固定されているため、固定長パラメータが使用していないパラメータインデックスを見つけて順番に配置していく、という考慮が複雑でした。

この苦労はこの辺に現れています。
https://github.com/sidepelican/SwiftSqlcPractice/blob/935d6c88f09e0fb48efb0e16fa005a309739a261/Sources/SqlcQuery.swift#L85-L119

他ツールとの比較

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