GORMでPostgresのjsonb型を扱う

GORMでPostgresのjsonb型をシンプルな実装で扱う方法についてご紹介します。

# Postgresのjsonb型
PostgresではJSONを扱うデータ型としてjson型の他にjsonb型が用意されています。jsonb型の方が下記の通り、公式でも利用が推奨されていてデータ取得時のパフォーマンスが良くなるなどのメリットが有るようです。

https://www.postgresql.jp/document/16/html/datatype-json.html

> json型とjsonb型というデータ型は、ほとんど 同一の入力値セットを受け入れます。 現実的に主要な違いは効率です。 jsonデータ型は入力テキストの正確なコピーで格納し、処理関数を実行するたびに再解析する必要があります。 jsonbデータ型では、分解されたバイナリ形式で格納されます。 格納するときには変換のオーバーヘッドのため少し遅くなりますが、処理するときには、全く再解析が必要とされないので大幅に高速化されます。 また jsonb型の重要な利点はインデックスをサポートしていることです。

> 一般的に、ほとんどのアプリケーションではJSONデータ型としてjsonb型のほうが望ましいでしょう。ただし、オブジェクトキーを従来のような順序であることを仮定する非常に特殊なニーズが存在するような場合は除きます。

# GORM公式でのjsonbのサンプル実装
GORMでjsonbを使おうと思ったときに、公式のサンプル実装は以下のようになっています（2024/6/26現在）

https://gorm.io/docs/data_types.html#Scanner-x2F-Valuer
>```golang
>type JSON json.RawMessage
>
>// Scan scan value into Jsonb, implements sql.Scanner interface
>func (j *JSON) Scan(value interface{}) error {
>  bytes, ok := value.([]byte)
>  if !ok {
>    return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
>  }
>
>  result := json.RawMessage{}
>  err := json.Unmarshal(bytes, &result)
>  *j = JSON(result)
>  return err
>}
>
>// Value return json value, implement driver.Valuer interface
>func (j JSON) Value() (driver.Value, error) {
>  if len(j) == 0 {
>    return nil, nil
>  }
>  return json.RawMessage(j).MarshalJSON()
>}
>```

この実装は`json.RawMessage`型を用いる方式で、実際にデータを取り出すには更にパース処理が必要になります。可変のJSONの場合には利点がありますが、固定の形式のJSONを扱う場合には、ちょっと扱いずらそうです。

# シンプルな実装例
以下のような、ユーザーが`ロールIDのスライス`と`レジュメの構造体`を保持するデータを例にします。
```golang
type User struct {
	gorm.Model // これはGORMのおまじない（id,created_at,updated_at,deleted_atを良しなに処理してくれる）
	Name    string
	RoleIDs RoleIDs // ここをjsonbで扱う
	Resume  Resume  // ここをjsonbで扱う
}

type RoleID int

type RoleIDs []RoleID

type Resume struct {
	Summary     string
	Experiences []string
	Skills      []string
}
```
DBスキーマはこんな感じです。
```sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    role_ids jsonb NOT NULL DEFAULT '[]',
    resume jsonb NOT NULL DEFAULT '{}',
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
    deleted_at TIMESTAMP
);
```

jsonbで扱う型の`Value()関数`と`Scan()関数`を実装します。
これらの関数はGORMが良しなに呼んでくれます。
- `Value()関数`は、保存や更新時に呼ばれます。
- `Scan()関数`は、取得時に呼ばれます。
　
- RoleIDs
    ```golang
      // RoleIDs Value Marshal
    func (ids RoleIDs) Value() (driver.Value, error) {
        return json.Marshal(ids)
    }

    // RoleIDs Scan Unmarshal
    func (ids *RoleIDs) Scan(value interface{}) error {
        b, ok := value.([]byte)
        if !ok {
            return errors.New("User: type assertion to []byte failed")
        }
        return json.Unmarshal(b, &ids)
    }
    ```
- Resume
    ```golang
    // Resume Value Marshal
    func (resume Resume) Value() (driver.Value, error) {
        return json.Marshal(resume)
    }

    // Resume Scan Unmarshal
    func (resume *Resume) Scan(value interface{}) error {
        b, ok := value.([]byte)
        if !ok {
            return errors.New("Resume: type assertion to []byte failed")
        }
        return json.Unmarshal(b, &resume)
    }
    ```

実装するのはこれだけです。簡単ですよね。
`RoleIDs`と`Resume`の実装は型の違いだけで中身は全く同じです。

# 動かしてみる
上記の型を用いてデータを挿入し、その挿入したデータを取得してログ出力する実装です。
```golang
func main() {
	dsn := "host=db user=postgres password=passw0rd dbname=postgres sslmode=disable"
	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
	if err != nil {
		log.Fatal(err)
	}

	// INSERT
	user := User{
		Name:    "Fujiwara Takumi",
		RoleIDs: RoleIDs{1, 2, 3},
		Resume: Resume{
			Summary: "Hogehoge",
			Experiences: []string{
				"Experience 1",
				"Experience 2",
			},
			Skills: []string{
				"Skill 1",
				"Skill 2",
			},
		},
	}
	result := db.Create(&user)
	if result.Error != nil || result.RowsAffected != 1 {
		log.Println("create user error:", result.Error)
		return
	}
	insertedID := user.ID

	// SELECT
	var selectedUser User
	if err := db.First(&selectedUser, insertedID).Error; err != nil {
		log.Println("select user error:", err)
		return
	}

	log.Printf("%+v\n", selectedUser)
}
```

実行結果
```
2024/06/26 01:03:55 
{
 Model:{
  ID:5
  CreatedAt:2024-06-26 01:03:55.006088 +0000 UTC 
  UpdatedAt:2024-06-26 01:03:55.006088 +0000 UTC 
  DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}
 } 
 Name:Fujiwara Takumi 
 RoleIDs:[1 2 3] 
 Resume:{
   Summary:Hogehoge 
   Experiences:[Experience 1 Experience 2] 
   Skills:[Skill 1 Skill 2]
 }
}
```
＊インデントは若干加工してます

保存と取得がちゃんと動いてることを確認できました🤓
　
　

今回使ったコード等はこちらに置いてます。

https://github.com/fujiwaram/gorm-jsonb-test