LoginSignup
14
7

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

Postgresのjsonb型

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

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

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

GORM公式でのjsonbのサンプル実装

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

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のスライスレジュメの構造体を保持するデータを例にします。

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スキーマはこんな感じです。

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
      // 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
    // 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)
    }
    

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

動かしてみる

上記の型を用いてデータを挿入し、その挿入したデータを取得してログ出力する実装です。

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]
 }
}

*インデントは若干加工してます

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

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

14
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
7