every Tech Blog

株式会社エブリーのTech Blogです。

AIレビューをきっかけにSQLBoilerの内部構造を調べた話

目次

はじめに

こんにちは。開発本部開発1部デリッシュキッチンMS2に所属している惟高です。

私が現在関わっているプロジェクトでは、SQLBoiler という ORM を使って既存の MySQL スキーマから Go のモデルコードを自動生成しています。

普段は SQLBoiler が生成してくれるモデルを中身を覗かず「ブラックボックス」として使っていますが、ある日の AI コードレビューからのコメントをきっかけに、その挙動を丁寧に追うことにしました。

この記事では、SQLBoiler がテーブル定義をどのように読み取りモデルを生成するのかをたどりながら、普段は意識していなかった内部構造を改めて整理していきます。

Note: SQLBoiler は現在メンテナンスモードのため、将来的に別 ORM への切り替えを検討する可能性があります。この記事は現行運用の振り返りとしてご覧ください。

SQLBoilerのコード生成フローをおさらい

SQLBoiler の CLI は、Cobra ベースのコマンドからドライバーとテンプレートを組み合わせてモデルを出力します。

実装では boilingcore.New が呼ばれ、設定を元にスキーマを読み取りテンプレートを準備します。

大まかな流れは以下のとおりです。

  1. スキーマ情報の取得: DB ドライバーが drivers.Tabledrivers.Column 構造体にメタデータを詰めます。 ここで列ごとの DefaultNullable の情報が集約されます。

  2. 列のカテゴリー分け: 取得したカラムは FilterColumnsByDefaultFilterColumnsByAuto などの関数で種類ごとに分類されます。 たとえば Default 文字列が空なら「デフォルトなし」、AutoGeneratedtrue なら「DB が自動で値を生成してくれる列」といった具合にルール化されます。

  3. テンプレート生成: 上記の結果がテンプレートに渡され、models パッケージのコードとして出力されます。 テンプレートコード の先頭で ColumnsWithoutDefaultColumnsWithDefault の配列が生成されるのがその一例です。

調査のきっかけになったAIレビュー

あるテーブルに nullable な group_id カラムを追加したところ、自動生成コードにおける columnsWithoutDefault にもその列が追加され、レビューボットから「ここに載っているなら必須では?」という指摘を受けました。

今回のマイグレーション

ALTER TABLE schema_version_cv_invalid_conditions
  ADD COLUMN group_id INT NULL DEFAULT NULL;

AIレビューでの指摘

SQLBoiler が出力した差分は次のようなものでした。

var schemaVersionCVInvalidConditionColumnsWithoutDefault = []string{
  "schema_version_id",
  "target_column",
  "condition_type",
  "group_id"  // 今回追加された箇所
}

「NULL を許す設計だったはずなのに必須扱い?」と違和感を覚え、SQLBoiler が生成したモデルコードを確認してみました。

columnsWithoutDefault が示すもの

今回は AI レビューで指摘があった columnsWithoutDefault について調査した結果をまとめていきます。

結論から言うと、ColumnsWithoutDefault は「DB が自動補完しない列の一覧」であり、必須かどうかを判定する仕組みではありませんでした。

テンプレートを辿ると、FilterColumnsByDefault(false, columns) の結果がそのまま ColumnsWithoutDefault として出力されていることが分かります。実装Column.Default の文字列が空かどうかを確認しているだけです。

Note: SQLBoiler の MySQL ドライバーでは column_default が SQL の NULL のとき、*string にスキャンした値が nil になるため Column.Default は空文字のままです。DEFAULT NULL を宣言した列もこのパターンに当たるので、ColumnsWithoutDefault 側に分類されます。(該当コード

columnsWithoutDefault の使用用途は以下があります。

   wl, _ := columns.InsertColumnSet(
     {{$alias.DownSingular}}AllColumns,
     {{$alias.DownSingular}}ColumnsWithDefault,
     {{$alias.DownSingular}}ColumnsWithoutDefault,
     nzDefaults,
   )

ここでは ColumnsWithoutDefault をベースに、モデル側で値が設定された ColumnsWithDefault を足し合わせたうえで INSERT に載せる列(wl)を決めています。

   randomize.Struct(seed, &a, {{$ltable.DownSingular}}DBTypes, false,
     strmangle.SetComplement({{$ltable.DownSingular}}PrimaryKeyColumns,
       {{$ltable.DownSingular}}ColumnsWithoutDefault)...)

テストコードでも ColumnsWithoutDefault を補助配列として利用できます。

例えば「親レコードに子レコードをひも付けるテスト」(SetChild などの関連付け処理)では、DB が補ってくれない列だけを取り出してランダムなダミーデータで埋め、その状態で関連付けメソッドが意図どおり機能するかを確認する用途に使っています。

実際の挙動を確かめる

生成されたモデル構造体では、該当する列は null.Int のような nullable 型で表現されます。

type SchemaVersionCVInvalidCondition struct {
    // 今回追加された部分のみを表示
    GroupID null.Int `boil:"group_id" json:"group_id,omitempty" toml:"group_id" yaml:"group_id,omitempty"`
}

null.Int{Valid:false} のまま Insert() するとクエリには列が含まれず、MySQL 側では NULL が保存されます。

まとめ — AIレビューとの付き合い方

今回、AI コードレビューの「必須では?」という指摘をきっかけに、SQLBoiler の内部構造について学ぶことができ、特にcolumnsWithoutDefault がどのように生成・利用されているかを改めて確認できました。

あわせて、columnsWithoutDefault のような自動生成コードは仕様を知らないと誤検知を招きやすいので、AI レビューではレビュー対象外にするなど運用でノイズを抑える工夫も必要だと感じました。

これから AI レビューを使う機会は増えていくはずです。 だからこそ、指摘を鵜呑みにせず該当コードや生成ロジックを辿って本当に正しいかどうか確かめる姿勢を持っていたいと思います。

少しでも参考になれば幸いです。最後まで読んでいただき、ありがとうございました。