emahiro/b.log

日々の勉強の記録とか育児の記録とか。

sql.NullXXX を sql.Null[XXX] に移行したら JSON の下位互換が壊れた話

Overview

Go 1.22 で追加された sql.Null[T] に既存の sql.NullString 等を置き換えたところ、DB 層の操作は問題なく動くのに JSONシリアライズ/デシリアライズで下位互換が壊れる という事象に遭遇した。

原因はシンプルで、構造体のフィールド名が変わったことによる JSON キーの変更。ランタイムで Valid: true なのに値が空という状態になり、不具合として発現した。

何が起きたか

旧: sql.NullString

type NullString struct {
    String string
    Valid  bool
}

これを encoding/jsonシリアライズすると以下のようになる。

{"String": "", "Valid": true}

新: sql.Null[string]

type Null[T any] struct {
    V     T
    Valid bool
}

同じ値をシリアライズするとこうなる。

{"V": "", "Valid": true}

String だったキーが V に変わる。 これだけの話だが、JSON を介してデータをやり取りしている箇所では致命的になる。

具体的にどう壊れたか

たとえば以下のようなフローがあったとする。

Writer(旧実装)→ JSON 永続化 → Reader(新実装)

Writer 側がまだ sql.NullString で書き込んでいる、もしくは過去に書き込んだ JSON が残っている場合、保存されている JSON は以下の形式になっている。

{"String": "some_value", "Valid": true}

これを sql.Null[string] でデシリアライズすると、V というキーを探しに行くので String キーの値は無視される。結果、以下の状態になる。

sql.Null[string]{
    V:     "",     // ← 空文字。"String" キーは V にマッピングされない
    Valid: true,   // ← true のまま。キー名は同じなので正常にパースされる
}

Valid: true かつ V: "" という状態が生まれる。コード上は「値がある」と判定されるが、実際の値は空。これがランタイムの不具合になった。

再現コード

package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
)

func main() {
    // 旧実装で書き込まれた JSON を想定
    oldJSON := `{"String": "hello", "Valid": true}`

    // 新しい型でデシリアライズ
    var v sql.Null[string]
    if err := json.Unmarshal([]byte(oldJSON), &v); err != nil {
        fmt.Println("error:", err)
        return
    }

    fmt.Printf("V: %q\n", v.V)       // V: ""
    fmt.Printf("Valid: %v\n", v.Valid) // Valid: true
    // → Valid が true なのに値が空
}

逆方向も同様で、新実装で書いた {"V": "hello", "Valid": true} を旧実装の sql.NullString でパースすると String が空になる。

なぜ気づきにくいか

  • DB 層では完全に互換がある。 Scan / Value の挙動は変わらないので、DB の読み書きだけなら問題は起きない。
  • go vetstaticcheck では検出できない。 型として正しいので静的解析では引っかからない。
  • Valid が正常にパースされるので一見成功している。 エラーも返らない。encoding/json は未知のキーをデフォルトで無視する。
  • go-modernize のような自動リファクタリングツールで機械的に置き換えると特に危険。 コンパイルは通るし DB 周りのテストも通る。

対策

1. カスタム JSON タグで互換性を維持する

sql.Null[T] を直接 JSONシリアライズしている箇所がある場合、ラッパー型を定義して JSON タグで旧キー名を維持する手がある。

type NullString struct {
    sql.Null[string]
}

func (n NullString) MarshalJSON() ([]byte, error) {
    return json.Marshal(struct {
        String string `json:"String"`
        Valid  bool   `json:"Valid"`
    }{
        String: n.V,
        Valid:  n.Valid,
    })
}

func (n *NullString) UnmarshalJSON(data []byte) error {
    var aux struct {
        String string `json:"String"`
        Valid  bool   `json:"Valid"`
    }
    if err := json.Unmarshal(data, &aux); err != nil {
        return err
    }
    n.V = aux.String
    n.Valid = aux.Valid
    return nil
}

2. そもそも sql.Null を JSON に入れない

sql.Null[T] はあくまで DB 層の NULL 表現であって、JSONシリアライズ用途には向いていない。API レスポンスやキャッシュの永続化にはドメイン固有の型やポインタ型(*string)を使う方が安全。

3. 移行時にデータマイグレーションを挟む

既存の永続化データがある場合、型の移行と同時にデータのマイグレーションが必要。旧フォーマットの JSON を新フォーマットに変換するバッチを走らせるか、デシリアライズ時に両方のキーを受け付けるようにする。

まとめ

sql.NullXXXsql.Null[T] の移行は DB 層だけ見ると完全互換だが、JSON を経由する箇所では破壊的変更になる。フィールド名が String / Int64 / Float64 等から一律 V に変わるため、JSON キーが暗黙に変わる。

go-modernize 等で機械的に置き換える場合、JSON シリアライズしている箇所がないか grep で確認してから適用した方がいい。

# 影響箇所の洗い出し
grep -rn 'sql\.Null\(String\|Int64\|Float64\|Bool\|Int16\|Int32\|Byte\|Time\)' --include='*.go' | \
  xargs -I {} grep -l 'encoding/json\|json\.Marshal\|json\.Unmarshal'

型の移行で JSON の互換性が壊れる、という観点はつい見落としがちなので書き残しておく。

※ この記事は試験的に AI に書かせています。