0
0

GORMライブラリのアソシエーションを試してみる

Posted at

はじめに

Go言語およびGORMライブラリの初学者である私は、ドキュメントを見ても部分的な情報だけではイマイチ使い方が把握できませんでした。
ですので、実際に書いて理解した内容を備忘録として載せておきます。

今回は次のアソシエーションを試してみました。

  • belongs to(多対1 または 1対1)
  • has one(1対1)
  • has many(1対多)
  • many to many(多対多)

検証環境

今回は実行結果をJson形式で確認したかったので、Go+Gin+GORMの構成で進めます。
開発環境の構築は以下を利用しています。

実行条件

Golang v1.22.3
Gin v1.10.0
GORM v1.25.10
air v1.52.0

MySQL 8.0.29

ディレクトリ構成

最終的には以下のようになります。

.
├── Dockerfile
├── controllers
│   └── sample_controller.go
├── docker-compose.yml
├── go.mod
├── go.sum
├── main.go
├── models
│   ├── database.go
│   └── items.go
├── routes
│   └── api_route.go
├── services
│   └── sample.go
├── tmp
└── utilities

データベース

次のようなデータを作成し、これをitemsテーブルを起点にして取得できるように実装を進めていきます。

mysql> select * from db_test.items;
+----+------------+-------------+
| id | company_id | name        |
+----+------------+-------------+
|  1 |          1 | item_name_1 |
|  2 |          1 | item_name_2 |
|  3 |          2 | item_name_3 |
+----+------------+-------------+
3 rows in set (0.00 sec)

mysql> select * from db_test.companies;
+----+----------------+
| id | name           |
+----+----------------+
|  1 | company_name_1 |
|  2 | company_name_2 |
+----+----------------+
2 rows in set (0.00 sec)

mysql> select * from db_test.players;
+----+----------+--------------+
| id | name     | init_item_id |
+----+----------+--------------+
|  1 | player_1 |            1 |
|  2 | player_2 |            2 |
|  3 | player_3 |         NULL |
+----+----------+--------------+
3 rows in set (0.00 sec)

mysql> select * from db_test.tags;
+----+---------+-------+
| id | item_id | name  |
+----+---------+-------+
|  1 |       1 | tag_1 |
|  2 |       1 | tag_2 |
+----+---------+-------+
2 rows in set (0.00 sec)

mysql> select * from db_test.item_images;
+---------+----------+
| item_id | image_id |
+---------+----------+
|       1 |        1 |
|       1 |        2 |
|       2 |        3 |
+---------+----------+
3 rows in set (0.00 sec)

mysql> select * from db_test.images;
+----+-----------------------+----------------+
| id | data                  | name           |
+----+-----------------------+----------------+
|  1 | product_image_data1-1 | image_name_1-1 |
|  2 | product_image_data1-2 | image_name_1-2 |
|  3 | product_image_data2-1 | image_name_2-1 |
+----+-----------------------+----------------+
3 rows in set (0.00 sec)

itemsテーブルを起点に雑に結合してみるとこんな感じです。
この結果と同じものを取得するわけではありませんが、参考までに。

mysql> select i.*,c.*,p.*,t.*,img.*
    -> from items i
    -> left join companies c on  i.company_id = c.id
    -> left join players p on  i.id = p.init_item_id
    -> left join tags t on  i.id = t.item_id
    -> left join item_images ii on  i.id = ii.item_id
    -> left join images img on  ii.image_id = img.id;
+----+------------+-------------+------+----------------+------+----------+--------------+------+---------+-------+------+-----------------------+----------------+
| id | company_id | name        | id   | name           | id   | name     | init_item_id | id   | item_id | name  | id   | data                  | name           |
+----+------------+-------------+------+----------------+------+----------+--------------+------+---------+-------+------+-----------------------+----------------+
|  1 |          1 | item_name_1 |    1 | company_name_1 |    1 | player_1 |            1 |    2 |       1 | tag_2 |    1 | product_image_data1-1 | image_name_1-1 |
|  1 |          1 | item_name_1 |    1 | company_name_1 |    1 | player_1 |            1 |    2 |       1 | tag_2 |    2 | product_image_data1-2 | image_name_1-2 |
|  1 |          1 | item_name_1 |    1 | company_name_1 |    1 | player_1 |            1 |    1 |       1 | tag_1 |    1 | product_image_data1-1 | image_name_1-1 |
|  1 |          1 | item_name_1 |    1 | company_name_1 |    1 | player_1 |            1 |    1 |       1 | tag_1 |    2 | product_image_data1-2 | image_name_1-2 |
|  2 |          1 | item_name_2 |    1 | company_name_1 |    2 | player_2 |            2 | NULL |    NULL | NULL  |    3 | product_image_data2-1 | image_name_2-1 |
|  3 |          2 | item_name_3 |    2 | company_name_2 | NULL | NULL     |         NULL | NULL |    NULL | NULL  | NULL | NULL                  | NULL           |
+----+------------+-------------+------+----------------+------+----------+--------------+------+---------+-------+------+-----------------------+----------------+
6 rows in set (0.00 sec)

うまく取得できることを期待して、やってみましょう。

ルーティング

main.go
package main

import (
    "go_gin_gorm/routes"
)

func main() {
    router := routes.GetApiRouter()
    router.Run(":8080")
}
routes/api_route.go
package routes

import (
    "github.com/gin-gonic/gin"
    "go_gin_gorm/controllers"
)

func GetApiRouter() *gin.Engine {
    r := gin.Default()

    // /sample?ids=1,2,3の形でリクエストする
    r.GET("/sample", controllers.SampleGetDatas)
    // /sample/10の形でリクエストする
    r.GET("/sample/:id", controllers.SampleGetOne)
    r.GET("/sample/all", controllers.SampleGetAll)

    return r
}

今回は3つの確認用APIを実装します。

/sample はクエリパラメータを持ち、指定したアイテムID(複数指定可能)の取得結果を返すことを想定しています。
/sample/all はすべてのアイテムの取得結果を返します。
/sample/:id はidに指定したアイテムIDの取得結果を返します。

モデル

models/database.go
package models

import (
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var Db *gorm.DB

func init() {
    Db = Connect()
}

func Connect() *gorm.DB {
    //DB接続
    dsn := "root@tcp(mysql:3306)/db_test?charset=utf8mb4&parseTime=True&loc=Local"
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})

    if err != nil {
        panic("failed to connect database")
    }
    return db
}

database.go ではDB接続を行います。
今回はデータベース名をdb_testとしてします。

models/items.go
package models

type Item struct {
    Id int
    Name   string
    CompanyId int
    Company Company `gorm:"references:Id"`
    Player Player `gorm:"foreignKey:InitItemId"`
    Tags []Tag
    Images []Image `gorm:"many2many:item_images;"`
}

// Belongs To
type Company struct {
    Id int
    Name   string
}

// Has One
type Player struct {
    Id int
    Name   string
    InitItemId   int
}

// Has Many
type Tag struct {
    Id int
    ItemId int
    Name   string
}

// Many To Many
type Image struct {
    Id int
    Data string
    Name   string
}

// 1件取得用
func GetOneItems(id int) (data Item, err error) {
    err = Db.Debug().Preload("Company").Preload("Player").Preload("Tags").Preload("Images").First(&data, id).Error

    return
}

// n件取得用
func GetItems(ids []int) (datas []Item, err error) {
    err = Db.Debug().Preload("Company").Preload("Player").Preload("Tags").Preload("Images").Find(&datas, ids).Error

    return
}

// 全件取得用
func GetAllItems() (datas []Item, err error) {
    err = Db.Debug().Preload("Company").Preload("Player").Preload("Tags").Preload("Images").Find(&datas).Error

    return
}

本題のDB制御部分ですね。順にみていきましょう

Belongs To

type Item struct {
...
    CompanyId int
    Company Company `gorm:"references:Id"`
...
}

// Belongs To(多対1)
type Company struct {
    Id int
    Name   string
}

Belongs To は「~属する」という意味です。ここではitemsテーブルが属するcompaniesテーブルのレコードを取得します。
BelongsToを実現するために`gorm:"references:参照フィールド"`を書きます。(主キーが対象であれば書かなくても動きます)
itemsテーブルのカラムcompany_idは外部キーです。companies.idを参照しています。

次のように参照フィールドと外部キーの両方を書くこともできます。
Company Company `gorm:"foreignKey:CompanyId;references:Id"`

Has One(1対1)

type Item struct {
    Id int
...
    Player Player `gorm:"foreignKey:InitItemId"`
...
}

// Has One
type Player struct {
    Id int
    Name   string
    InitItemId   int
}

Has One は主テーブルのレコードに対して、従テーブルの1つのレコードが紐付けられるときに用いられます。
HasOneを実現するために`gorm:"foreignKey:外部キー"`を書きます。
playersテーブルのinit_item_idは外部キーです。items.idを参照しています。

Has One(1対多)

type Item struct {
    Id int
...
    Tags []Tag
...
}

// Has Many
type Tag struct {
    Id int
    ItemId int
    Name   string
}

Has Many はHas Oneと非常に良く似ています。複数取得するか単数取得するかの違いだけです。
tagsテーブルのitem_idは外部キーです。items.idを参照しています。

通常は所有する側のモデルの主キーをリレーションの外部キーの値として使用するため、DB構成次第でタグを指定しなくてもアソシエーションを利用できますが、タグを指定する場合は以下のようになります。

Tags []Tag `gorm:"foreignKey:ItemId"`

Many To Many(多対多)

type Item struct {
    Id int
...
    Images []Image `gorm:"many2many:item_images;"`
}

// Many To Many
type Image struct {
    Id int
    Data string
    Name   string
}

Many To Many は多対多を取得する場合に用いられます。今回の場合、多対多を実現するための中間テーブルitem_imagesを使用します。
中間テーブルを用いてManyToManyを実現するためには、`gorm:"many2many:中間テーブル名;"`を書きます。
記載していませんが、item_imagesはitems.idとimages.idの外部キーを持ちます。

データの取得方法

リレーションはPreloadを用いることで取得しています。
引数に指定するのは構造体で定義した変数名を指定します。

    err = Db.Debug().Preload("Company").Preload("Player").Preload("Tags").Preload("Images").First(&data, id).Error

Debug()を使うことで、この処理が実行された際にSQLがログ出力されるようになります。

サービス

ビジネスロジックは適当です。DBアクセスした結果を制御しています。

services/sample.go
package services

import (
    "go_gin_gorm/models"
    "fmt"
)

func SampleGetOne(id int) (models.Item, error) {
    data, err := models.GetOneItems(id)
    if err != nil {
        return data, fmt.Errorf(err.Error())
    }
    if data.Id == 0 {
        return data, fmt.Errorf("データがありません")
    }
    return data, nil
}

func SampleGetDatas(ids []int) ([]models.Item, error) {
    datas, err := models.GetItems(ids)
    if err != nil {
        return datas, fmt.Errorf(err.Error())
    }
    if len(datas) == 0 {
        return nil, fmt.Errorf("データがありません")
    }
    return datas, nil
}

func SampleGetAll() ([]models.Item, error) {

    datas, err := models.GetAllItems()
    if err != nil {
        return nil, fmt.Errorf(err.Error())
    }
    if len(datas) == 0 {
        return nil, fmt.Errorf("データがありません")
    }

    return datas, nil

}

コントローラ

ユーザーからのリクエストを受け取る部分です。

controllers/sample_controller.go
package controllers

import (
    "github.com/gin-gonic/gin"
    "net/http"
    "go_gin_gorm/services"
    "strconv"
    "strings"
)

func SampleGetOne(c *gin.Context) {
    // エンドポイントから取得。取得値はすべて文字列になるため型変換する
    id,_ := strconv.Atoi(c.Param("id"))

    data, err := services.SampleGetOne(id);
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error" : err.Error(),
        })
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "data" : data,
    })
}

func SampleGetDatas(c *gin.Context) {
    // クエリパラメータを取得し、分割
    ids := strings.Split(c.Query("ids"), ",")

    // int型にしたいため変換(ほかにいい方法あるか?)
    int_ids := make([]int, len(ids))
    for i, v := range ids {
        int_ids[i],_ = strconv.Atoi(v)
    }

    datas, err := services.SampleGetDatas(int_ids);
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error" : err.Error(),
        })
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "datas" : datas,
    })
    
}

func SampleGetAll(c *gin.Context) {
    datas, err := services.SampleGetAll();
    if err != nil {
        c.JSON(http.StatusBadRequest, gin.H{
            "error" : err.Error(),
        })
        return
    }
    
    c.JSON(http.StatusOK, gin.H{
        "datas" : datas,
    })
}

いくつか補足します。

    id,_ := strconv.Atoi(c.Param("id"))

c.Param("id")の部分は、エンドポイントで:キーの形で指定された値を取得している部分です。
エンドポイントを /sample/:id としているので、/sample/10 の場合は10が取れることになります。
ちなみに取得結果は文字列になりますので、INT型に変換しています。変換できなかった場合は無視します

    // クエリパラメータを取得し、分割
    ids := strings.Split(c.Query("ids"), ",")

    // int型にしたいため変換(ほかにいい方法あるか?)
    int_ids := make([]int, len(ids))
    for i, v := range ids {
        int_ids[i],_ = strconv.Atoi(v)
    }

c.Query("ids") でリクエストのクエリパラメータを取得しています。
/sample?ids=10 と指定された場合は10が取れます。
/sample?ids=10,20とした場合は10と20が取れます。
クエリパラメータをカンマ区切りで分割し、1つずつINT型に変換して詰め直しています。

実行結果

さて、それではどのように値が取れるか見てみましょう。

1件の取得

リクエスト
$ curl -X GET "http://localhost:8080/sample/1"
レスポンス
{
    "data": {
        "Id": 1,
        "Name": "item_name_1",
        "CompanyId": 1,
        "Company": {
            "Id": 1,
            "Name": "company_name_1"
        },
        "Player": {
            "Id": 1,
            "Name": "player_1",
            "InitItemId": 1
        },
        "Tags": [
            {
                "Id": 1,
                "ItemId": 1,
                "Name": "tag_1"
            },
            {
                "Id": 2,
                "ItemId": 1,
                "Name": "tag_2"
            }
        ],
        "Images": [
            {
                "Id": 1,
                "Data": "product_image_data1-1",
                "Name": "image_name_1-1"
            },
            {
                "Id": 2,
                "Data": "product_image_data1-2",
                "Name": "image_name_1-2"
            }
        ]
    }
}
SQLログ
[0.385ms] [rows:2] SELECT * FROM `item_images` WHERE `item_images`.`item_id` = 1
[0.280ms] [rows:1] SELECT * FROM `companies` WHERE `companies`.`id` = 1
[0.470ms] [rows:2] SELECT * FROM `images` WHERE `images`.`id` IN (1,2)
[0.548ms] [rows:1] SELECT * FROM `players` WHERE `players`.`init_item_id` = 1
[3.725ms] [rows:1] SELECT * FROM `items` WHERE `items`.`id` = 1 ORDER BY `items`.`id` LIMIT 1
[0.863ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`item_id` = 1

複数指定による取得
リクエスト
$ curl -X GET "http://localhost:8080/sample?ids=1,3"
レスポンス
{
    "datas": [
        {
            "Id": 1,
            "Name": "item_name_1",
            "CompanyId": 1,
            "Company": {
                "Id": 1,
                "Name": "company_name_1"
            },
            "Player": {
                "Id": 1,
                "Name": "player_1",
                "InitItemId": 1
            },
            "Tags": [
                {
                    "Id": 1,
                    "ItemId": 1,
                    "Name": "tag_1"
                },
                {
                    "Id": 2,
                    "ItemId": 1,
                    "Name": "tag_2"
                }
            ],
            "Images": [
                {
                    "Id": 1,
                    "Data": "product_image_data1-1",
                    "Name": "image_name_1-1"
                },
                {
                    "Id": 2,
                    "Data": "product_image_data1-2",
                    "Name": "image_name_1-2"
                }
            ]
        },
        {
            "Id": 3,
            "Name": "item_name_3",
            "CompanyId": 2,
            "Company": {
                "Id": 2,
                "Name": "company_name_2"
            },
            "Player": {
                "Id": 0,
                "Name": "",
                "InitItemId": 0
            },
            "Tags": [],
            "Images": []
        }
    ]
}
SQLログ
[0.234ms] [rows:2] SELECT * FROM `companies` WHERE `companies`.`id` IN (1,2)
[0.259ms] [rows:2] SELECT * FROM `item_images` WHERE `item_images`.`item_id` IN (1,3)
[0.436ms] [rows:2] SELECT * FROM `images` WHERE `images`.`id` IN (1,2)
[0.334ms] [rows:1] SELECT * FROM `players` WHERE `players`.`init_item_id` IN (1,3)
[0.346ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`item_id` IN (1,3)
[2.551ms] [rows:2] SELECT * FROM `items` WHERE `items`.`id` IN (1,3)

全件取得

リクエスト
$ curl -X GET "http://localhost:8080/sample/all"
レスポンス
{
    "datas": [
        {
            "Id": 1,
            "Name": "item_name_1",
            "CompanyId": 1,
            "Company": {
                "Id": 1,
                "Name": "company_name_1"
            },
            "Player": {
                "Id": 1,
                "Name": "player_1",
                "InitItemId": 1
            },
            "Tags": [
                {
                    "Id": 1,
                    "ItemId": 1,
                    "Name": "tag_1"
                },
                {
                    "Id": 2,
                    "ItemId": 1,
                    "Name": "tag_2"
                }
            ],
            "Images": [
                {
                    "Id": 1,
                    "Data": "product_image_data1-1",
                    "Name": "image_name_1-1"
                },
                {
                    "Id": 2,
                    "Data": "product_image_data1-2",
                    "Name": "image_name_1-2"
                }
            ]
        },
        {
            "Id": 2,
            "Name": "item_name_2",
            "CompanyId": 1,
            "Company": {
                "Id": 1,
                "Name": "company_name_1"
            },
            "Player": {
                "Id": 2,
                "Name": "player_2",
                "InitItemId": 2
            },
            "Tags": [],
            "Images": [
                {
                    "Id": 3,
                    "Data": "product_image_data2-1",
                    "Name": "image_name_2-1"
                }
            ]
        },
        {
            "Id": 3,
            "Name": "item_name_3",
            "CompanyId": 2,
            "Company": {
                "Id": 2,
                "Name": "company_name_2"
            },
            "Player": {
                "Id": 0,
                "Name": "",
                "InitItemId": 0
            },
            "Tags": [],
            "Images": []
        }
    ]
}
SQLログ
[0.511ms] [rows:2] SELECT * FROM `companies` WHERE `companies`.`id` IN (1,2)
[0.578ms] [rows:3] SELECT * FROM `item_images` WHERE `item_images`.`item_id` IN (1,2,3)
[0.399ms] [rows:3] SELECT * FROM `images` WHERE `images`.`id` IN (1,2,3)
[0.454ms] [rows:2] SELECT * FROM `players` WHERE `players`.`init_item_id` IN (1,2,3)
[0.336ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`item_id` IN (1,2,3)
[3.168ms] [rows:3] SELECT * FROM `items`

まとめ

いかがだったでしょうか。

Preloadによって複数のSQLが実行されているので、データ量が多くなるとかなり重くなりそうな気がしますが、使いのこなせると便利そうですね。

0
0
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
0
0