薄いブログ

技術の雑多なことを書く場所

sqlc plugin を書こう

背景

https://github.com/orisano/sqlc-gen-ts-d1 というプラグインを作成していて生成コードの好みが人によって大きく異なると感じることがありました。

一つのプラグインで生成コードをカスタマイズできるアプローチには保守性的な意味でも限界があるだろうと思いました。

気軽にプラグインが作れるようになることで自分の好みのコードが生成できるし、好みが似通ったコミュニティにメンテンスされているプラグインが一つでもあれば幸せな人も増えるかなと思ったため記事を書くことにしました。

とはいえ sqlc を使い始めるとき最初にプラグインを書くことはまずないし、デフォルトの生成コードが好みでなかったりそもそも対応してない場合は選択肢から外れるだけだと思います。

なので sqlc を使いたいという情熱のある人やプラグインを作りたいという人の役に立てばと幸いです。

plugin とは

plugin とは sqlc がパースしたスキーマと結果やパラメータの型が推論されたクエリの情報を使って処理を行うプログラムのことを指します。

大体の場合以下のようなことを行う必要があります。

  • SQL の型と対象のプログラミング言語の型の対応付け
  • コードの生成
  • スネークケースからキャメルケースへの変換

プラグインを作り始めた段階だと自分に必要な型以外は無視するくらいでちょうど良いです。 コードの生成の際に API の決定をしないといけないのでそこが面倒くさいかなという感じです。

プラグインの提供方式

プラグインの提供方式には以下の3つがあります。

  • codegen_request.json を使う方式
  • process plugin として提供する方式
  • wasm plugin として提供する方式

codegen_request.json を使う方式

まず2つのファイルを用意します。

sqlc.json

{
  "version": "2",
  "sql": [{
    "schema": "query.sql",
    "queries": "query.sql",
    "engine": "sqlite",
    "gen": {
      "json": {"out": "."}
    }
  }]
}

query.sql

CREATE TABLE account (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT UNIQUE NOT NULL
);

-- name: GetAccount :one
SELECT * FROM account WHERE id = @account_id;
$ sqlc generate

を実行すると codegen_request.json というファイルが生成されます。

{
  "settings": {
    "version": "2",
    "engine": "sqlite",
    "schema": [
      "query.sql"
    ],
    "queries": [
      "query.sql"
    ],
    "rename": {},
    "overrides": [],
    "codegen": {
      "out": "",
      "plugin": "",
      "options": ""
    },
    "go": {
      "emit_interface": false,
      "emit_json_tags": false,
      "emit_db_tags": false,
      "emit_prepared_queries": false,
      "emit_exact_table_names": false,
      "emit_empty_slices": false,
      "emit_exported_queries": false,
      "emit_result_struct_pointers": false,
      "emit_params_struct_pointers": false,
      "emit_methods_with_db_argument": false,
      "json_tags_case_style": "",
      "package": "",
      "out": "",
      "sql_package": "",
      "sql_driver": "",
      "output_db_file_name": "",
      "output_models_file_name": "",
      "output_querier_file_name": "",
      "output_files_suffix": "",
      "emit_enum_valid_method": false,
      "emit_all_enum_values": false,
      "inflection_exclude_table_names": [],
      "emit_pointers_for_null_types": false,
      "query_parameter_limit": 1,
      "output_batch_file_name": "",
      "json_tags_id_uppercase": false,
      "omit_unused_structs": false
    },
    "json": {
      "out": ".",
      "indent": "",
      "filename": ""
    }
  },
  "catalog": {
    "comment": "",
    "default_schema": "main",
    "name": "",
    "schemas": [
      {
        "comment": "",
        "name": "main",
        "tables": [
          {
            "rel": {
              "catalog": "",
              "schema": "",
              "name": "account"
            },
            "columns": [
              {
                "name": "id",
                "not_null": true,
                "is_array": false,
                "comment": "",
                "length": -1,
                "is_named_param": false,
                "is_func_call": false,
                "scope": "",
                "table": {
                  "catalog": "",
                  "schema": "",
                  "name": "account"
                },
                "table_alias": "",
                "type": {
                  "catalog": "",
                  "schema": "",
                  "name": "INTEGER"
                },
                "is_sqlc_slice": false,
                "embed_table": null,
                "original_name": "",
                "unsigned": false,
                "array_dims": 0
              },
              {
                "name": "name",
                "not_null": true,
                "is_array": false,
                "comment": "",
                "length": -1,
                "is_named_param": false,
                "is_func_call": false,
                "scope": "",
                "table": {
                  "catalog": "",
                  "schema": "",
                  "name": "account"
                },
                "table_alias": "",
                "type": {
                  "catalog": "",
                  "schema": "",
                  "name": "TEXT"
                },
                "is_sqlc_slice": false,
                "embed_table": null,
                "original_name": "",
                "unsigned": false,
                "array_dims": 0
              }
            ],
            "comment": ""
          }
        ],
        "enums": [],
        "composite_types": []
      }
    ]
  },
  "queries": [
    {
      "text": "SELECT id, name FROM account WHERE id = ?1",
      "name": "GetAccount",
      "cmd": ":one",
      "columns": [
        {
          "name": "id",
          "not_null": true,
          "is_array": false,
          "comment": "",
          "length": -1,
          "is_named_param": false,
          "is_func_call": false,
          "scope": "",
          "table": {
            "catalog": "",
            "schema": "",
            "name": "account"
          },
          "table_alias": "",
          "type": {
            "catalog": "",
            "schema": "",
            "name": "INTEGER"
          },
          "is_sqlc_slice": false,
          "embed_table": null,
          "original_name": "id",
          "unsigned": false,
          "array_dims": 0
        },
        {
          "name": "name",
          "not_null": true,
          "is_array": false,
          "comment": "",
          "length": -1,
          "is_named_param": false,
          "is_func_call": false,
          "scope": "",
          "table": {
            "catalog": "",
            "schema": "",
            "name": "account"
          },
          "table_alias": "",
          "type": {
            "catalog": "",
            "schema": "",
            "name": "TEXT"
          },
          "is_sqlc_slice": false,
          "embed_table": null,
          "original_name": "name",
          "unsigned": false,
          "array_dims": 0
        }
      ],
      "params": [
        {
          "number": 1,
          "column": {
            "name": "account_id",
            "not_null": true,
            "is_array": false,
            "comment": "",
            "length": -1,
            "is_named_param": true,
            "is_func_call": false,
            "scope": "",
            "table": {
              "catalog": "",
              "schema": "",
              "name": "account"
            },
            "table_alias": "",
            "type": {
              "catalog": "",
              "schema": "",
              "name": "INTEGER"
            },
            "is_sqlc_slice": false,
            "embed_table": null,
            "original_name": "id",
            "unsigned": false,
            "array_dims": 0
          }
        }
      ],
      "comments": [],
      "filename": "query.sql",
      "insert_into_table": null
    }
  ],
  "sqlc_version": "v1.20.0",
  "plugin_options": ""
}

catalog にテーブルの情報, queries にクエリの情報がある json です。

この json を用いて適当なコードを出力するプログラムを書いてみましょう。

今回は https://zenn.dev/voluntas/scraps/f8a2de562bac37 の TypeScript の型を生成してみます。

import json

def to_camel(x):
  xs = x.split("_")
  return xs[0] + "".join(map(str.title, xs[1:]))

with open("codegen_request.json") as f:
  codegen = json.load(f)

to_ts_type = {
  "INTEGER": "number",
  "TEXT": "string",
}
for query in codegen["queries"]:
  name = query["name"]
  print(f"type {name}Param = {{")
  for param in query["params"]:
    param_name = param["column"]["name"]
    param_type = param["column"]["type"]["name"]
    prop_name = to_camel(param_name)
    prop_type = to_ts_type[param_type]
    print(f"  {prop_name}: {prop_type}")
  print(f"}}")
  print(f"")
  print(f"type {name}Row = {{")
  for column in query["columns"]:
    col_name = column["name"]
    col_type = column["type"]["name"]
    prop_name = to_camel(col_name)
    prop_type = to_ts_type[col_type]
    print(f"  {prop_name}: {prop_type}")
  print(f"}}")
  print(f"")

上の Python スクリプトを実行すると以下の TypeScript が生成されます。

type GetAccountParam = {
  accountId: number
}

type GetAccountRow = {
  id: number
  name: string
}

最も簡単なプラグインの書き方は codegen_request.json を用いたこの方法だと思います。

私見

  • 正確にはプラグインではないが同等のことができる
  • 基本的にどの言語でもかけるので始めやすい
  • プロジェクト専用のプラグインならこれで十分
  • sqlc generate で完結しない。シェルスクリプトMakefile が必要になる
  • プログラムの実行環境が必要なので配布が面倒くさい

process plugin として提供する方式

codegen_request.json の方式で生成されていた codegen_request.json と同等の情報が protobuf として渡されます。

https://github.com/sqlc-dev/sqlc/blob/main/protos/plugin/codegen.proto

それを処理するプロセスを提供する方式です。

私見

  • protobuf が問題なく処理できて複数のプロジェクトで使いたい場合はこちら
  • wasm と違って自由に処理が行える
  • 複数のOSに対応する必要がある場合はクロスビルドができないと面倒くさいかもしれない
    • クロスビルドができても配布が複数必要になるのが面倒くさい

wasm plugin として提供する方式

protobuf を処理する wasm を提供する方式です。URL と SHA256 を指定します。

https://github.com/orisano/sqlc-gen-ts-d1 が参考になるかと思います。

私見

  • 広く使ってもらいたい場合はこちら
  • wasm にできれば複数OSを意識しなくてもよい
  • 使う側としては process plugin より安全
  • ただ手元での開発がめんどくさいことが多い
    • file スキームを使うようにする
  • sha256 による検証が必須

終わりに

plugin 開発を始めるときは codegen_request.json を使う方式が良いかなと個人的には思います。デバッグのためにも json として出力しておくのがおすすめです。

wasm にできるなら wasm plugin, そうでないなら process plugin というのが良いのではないでしょうか。

ぜひ sqlc plugin を作ってみてください。