[Note] GORM 筆記
TL;DR
- query @ gorm
- method chaining @ form
db.
Table("table_name"). // Model(&model{})
Select("select syntax").
Group("field_names").
Order("field_name").
Find(&model{}) // Rows()
- methods 可以分成 chain methods、finisher methods、new session method。
與 Database 連線
- Connecting to a Database @ gorm
除了下述說明外,建議可以參考 go-snippets/pkg/gorm:
import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func initDatabase(dsn string) (*database.GormDatabase, error) {
db, err := database.New(dsn, &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
return nil, err
}
if err := db.AutoMigrate(new(model.Organization)); err != nil {
log.Fatal(err.Error())
}
fmt.Println("Database connected ...")
return db, nil
}
func main() {
dsn := "sslmode=disable host=localhost port=5432 user=postgres dbname=gorm_sandbox"
db, err := initDatabase(dsn)
}
CRUD Interface
template/database/product @ go-snippets
Update
Update @ gorm
db.Update()
在 Update 中可以透過 struct
或 map
一次更新多個欄位,但要特別留意的是
- 使用 struct 的方式只會更新 non-zero value,也就是說如果值是
false
或0
這類的都不會被保存 - 使用
map
的方式才會更新 zero-value,因此個人會建議使用 map 的方式來避免可能的 bug
// Update attributes with `struct`, will only update non-zero fields
db.Model(&user).Updates(User{Name: "hello", Age: 18, Active: false})
// UPDATE users SET name='hello', age=18, updated_at = '2013-11-17 21:34:10' WHERE id = 111;
// Update attributes with `map`
db.Model(&user).Updates(map[string]interface{}{"name": "hello", "age": 18, "active": false})
// UPDATE users SET name='hello', age=18, active=false, updated_at='2013-11-17 21:34:10' WHERE id=111;
💡 會建議使用 map 的方式(而非 struct)來進行更新避免可能的 bug
db.Save()
使用 db.Save()
時,沒給的值會全部被清空(包括 createdAt
、除了 updatedAt
),所以如果要用 save,一定要把舊有的資料先拉出來,對要改變的內容進行修改後,在呼叫 save
:
// https://gorm.io/docs/update.html
// 先把原有資料拉出來
db.First(&user)
// 修改要改動的欄位就好
user.Name = "jinzhu 2"
user.Age = 100
// 使用 Save
db.Save(&user)
⚠️ 使用
db.Save()
時所有的欄位都會更新,因此若沒給的話會導致該值變回 zero-value,記得一定要先把資料拉出來後,修改要改的資料後,在呼叫 Save。
Upsert
Upsert-On-Conflict @ Create > Upsert
- 使用 Gorm 內建的 upsert 方法會類似 Save,有指定更新欄位的一定要給值,否則會被清空
- 使用
Where
+ Assign + FirstOrCreate` 組出來的 upsert 方法會類似 update,需要留意當使用者想要清空原本的資料時,因為後端接收到的是 zero value,所以不會執行清空的動作
根據單一欄位
upsert 的欄位需要是 unique
的才能,假設 upsert 時要根據的欄位只有一個,只需要將該欄位改成 unique
後即可:
type Product struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
Price uint
SN uint `gorm:"unique"`
Comment string
}
接著就可以根據這個欄位來決定要不要 Upsert:
⚠️ 如果希望被刪除的資料事後 Upsert 時可以復原,則在
Columns
中要加入deleted_at
。如果希望updated_at
在 upsert 時會一併更新,也要加在Columns
中。
func (d *GormDatabase) UpsertProduct(product *model.Product) error {
if err := d.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sn"}}, // 根據 sn 這個欄位判斷要不要 upsert
DoUpdates: clause.AssignmentColumns([]string{
"price", "comment", "name", "updated_at",
}), // 要更新的欄位
}).Create(&product).Error; err != nil {
return err
}
return nil
}
⚠️ 在
clause.AssignmentColumns()
有列出的欄位名稱都會被儲存,因此若沒給值的話,原本的值會被清空。
也可以直接使用 UpdateAll: true
:
// Update all columns expects "primary keys" to new value on conflict
db.Clauses(clause.OnConflict{
UpdateAll: true
}).Create(&users)
根據多個欄位
若 upsert 時要根據的欄位不只一個時,需要將多個欄位建立出一個 unique index
,舉例來說,想要同時根據 name
和 sn
來做 upsert 的方法。
model 的部分,會把 Name 和 SN 又定義相同的 uniqueIndex
(即,gorm:"uniqueIndex:name_sn"
):
type Product struct {
ID uuid.UUID `gorm:"primaryKey;type:uuid;default:uuid_generate_v4()"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string `gorm:"uniqueIndex:name_sn"`
Price uint
SN uint `gorm:"uniqueIndex:name_sn"`
Comment string
}
接著就可以同時根據這兩個欄位來做 upsert:
func (d *GormDatabase) UpsertProduct(product *model.Product) error {
if err := d.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sn"}, {Name: "name"}}, // 同時根據兩個欄位
DoUpdates: clause.AssignmentColumns([]string{"price", "comment"}), // 要更新的欄位
}).Create(&product).Error; err != nil {
return err
}
return nil
}
需要兩個欄位同時都一樣時才會執行 Upsert,只有一個一樣不會 Upsert。
如果希望只 upsert 有提供的欄位
使用者種方式時,要留意 zero value 的情況,也就是如果使用者想要把該欄位清空時,因為清空時該欄位值會是 zero value,可能會更新不到
// UpsertDevice returns the client for the given id or nil.
func (d *GormDatabase) UpsertDevice(deviceInfo *model.Device) error {
device := model.Device{}
if err := d.DB.
Where("box_device_id = ? AND nis_patient_id = ?", deviceInfo.BoxDeviceID, deviceInfo.NISPatientID).
Assign(deviceInfo).
FirstOrCreate(&device).Error; err != nil {
return err
}
if err := d.DB.Take(deviceInfo, device.ID).Error; err != nil {
return err
}
return nil
}
Model
慣例(conventions)
在 GORM 中,慣例上會:
- 以
ID
當作 PK- 如果在一個 model 中設定多個
primaryKey
則會是 Composite Primary Key。
- 如果在一個 model 中設定多個
- 以複數的
snake_cases
當作 table name,例如product_tags
- 以
snake_case
當作欄位的名稱,例如is_publish
- 使用
CreatedAt
和UpdatedAt
來追蹤新增和修改的時間
覆蓋掉預設的資料表名稱
keywords: Tabler
, TableName
透過實作 Tabler interface 中的 TableName() string
方法,可以修改 GORM 在 auto migrate 時建立的 Table 名稱。例如下述程式碼最終會在 DB 中建立名為 blog_tag
的資料表:
type Tag struct {
// ...
}
// 實作 Tabler interface,最終會在 DB 中建立名為 `blog_tag` 的資料表
func (t Tag) TableName() string {
return "blog_tag"
}
透過
TableName() string
可以覆蓋預設的資料表名稱,但若是希望動態產生不同的資料表名稱則需使用db.Scopes()
來達到,參考這裡和 NamingStrategy。
如果想要修改預設的欄位名稱,可以在定義 struct 時使用 gorm:"column_name:foo_bar"
或使用 NamingStrategy。
gorm.Model
GORM 在定義 Model 時,可以使用 gorm.Model
,這預設會帶入 這些欄位到該 Model 中:
ID
,CreatedAt
UpdateAt
DeleteAt
type User struct {
gorm.Model
Author *string `json:"author"` // 字串是 "",數值是 0
Title *string `json:"name"` // 使用 pointer 的話,存到 database 的預設值會是 null,否則會是 zero value
Rating int `json:"rating"` // 注意這裡 rating 沒有用雙引號
}
如果想要自己定義接收 JSON 中屬性的名稱,記得要加上雙引號。
- 接收 JSON 屬性為
"author"
,存到 database 後,該欄位名稱也是author
,從 database 取出時亦會以小寫開頭 - 接收 JSON 屬性為
"name"
,存到 database 後,該欄位名稱是title
,從 database 取出時亦會以小寫開頭 - 這裡
rating
沒有用雙引號,從 database 取出時會以「大寫」開頭,即Rating
Embedded Struct
如果需要在 Model 中直接嵌入(embedded)另一個 struct,而並非透過「參照」的方式指稱到另一個 model,可以使用 embedded
。
anonymous fields
// 程式來源:https://gorm.io/docs/models.html#Embedded-Struct
type User struct {
gorm.Model
Name string
}
// equals
type User struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
}
嵌入自己定義的 Struct
keywords: gorm:"embedded"
如果是想要 embedded 其他 struct,而不是要當成 has_one 或 belongs_to 這種關聯式 Model 使用的話,也可以加上 gorm:"embedded"
使用:
也就是說,可以直接透過 Blog.Name
取得作者的名稱,而不是 Blog.Author.Name
:
// 程式來源:https://gorm.io/docs/models.html#Embedded-Struct
type Author struct {
Name string
Email string
}
type Blog struct {
ID int
Author Author `gorm:"embedded"`
Upvotes int32
}
// equals
type Blog struct {
ID int64
Name string
Email string
Upvotes int32
}
在嵌入的欄位加上前綴
keywords: embeddedPrefix
若有需要在嵌入時為每個 struct 加上前綴的話,則可以加上 gorm:"embedded;embeddedPrefix:foo_"
的用法:
如此要原本 Author 欄位嵌入到 Blog 中後,會變成 AuthorName
和 AuthorEmail
:
// 程式來源:https://gorm.io/docs/models.html#Embedded-Struct
type Blog struct {
ID int
Author Author `gorm:"embedded;embeddedPrefix:author_"`
Upvotes int32
}
// equals
type Blog struct {
ID int64
AuthorName string
AuthorEmail string
Upvotes int32
}
Query
Query @ GORM
Find, First, Take
var user *model.User
var users []*model.User
db.Find(&user) // 列出所有 Users
db.Take(&user, 1) // 找出 PK 為 1 的 user
db.First(&user) // 列出第一個 User
db.First(&user, 1) // 找出 PK 為 1 且排序為第一個的 user
Where
db.Where("name = ?", "aaronchen").Take(&user) // 如果確定只會有一個人
db.Where("name = ?", "aaronchen").First(&user) // 從符合條件中以 PK 排序後取出第一個
Count
func (d *GormDatabase) CountProductByCondition(condition ...interface{}) (int64, error) {
var count int64
tx := d.DB.Model(&model.Product{})
if len(condition) == 1 {
tx.Where(condition[0])
} else if len(condition) > 1 {
tx.Where(condition[0], condition[1:]...)
}
err := tx.Count(&count).Error
return count, err
}
Many to Many
Many to Many @ form
Customize Join Table
在 gorm 中也可以自訂 join table,例如 user has and belongs to patients, patients has and belongs to user,這時候的 model 可以這樣定:
- 在 User 中有
Patients
欄位 - 在 Patient 中有
Users
欄位 - 使用
many2many:join_table_name;
可以自訂 Join Table 的名稱
// model/user.go
type User struct {
ID uint `gorm:"primaryKey;uniqueIndex;autoIncrement"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt
Username string `gorm:"type:varchar(20);uniqueIndex"`
Password string
Patients []Patient `gorm:"many2many:users_patients;"`
}
// model/patient.go
type Patient struct {
ID uint `gorm:"primaryKey;uniqueIndex;autoIncrement" json:"id"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `json:"-"`
Name string `json:"name"`
Phone *string `gorm:"type:varchar(20)" json:"phone" binding:"required"`
Users []User `gorm:"many2many:users_patients;"`
}
對於客製化的 Join Table(UsersPatients
) 可以是:
- Join Table 中有
UserID
和PatientID
- 還有新添加欄位
Relationship
// ./model/users_patients.go
type UsersPatients struct {
ID uint `gorm:"primaryKey;uniqueIndex;autoIncrement" json:"id"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
DeletedAt gorm.DeletedAt `json:"-"`
UserID uint `json:"userId"`
PatientID uint `json:"patientId"`
Relationship string `json:"relationship"`
}
在使用 Custom Join Table 的時候可以使用 SetupJoinTable
方法,如此 gorm 會幫忙處理 CreatedAt
, UpdatedAt
這些欄位:
- 在建立 Patient 時,一併把和 User 的關聯建立起來(也就是新增一筆記錄在 users_patients 資料表中)
- 在建立 Patient 前先使用
db.SetupJoinTable(model interface{}, field string, joinTable interface{})
的方法
// ./database/patient.go
func (d *GormDatabase) CreatePatient(patient *model.Patient) error {
if err := d.DB.SetupJoinTable(&model.Patient{}, "Users", &model.UsersPatients{}); err != nil {
return err
}
if err := d.DB.Create(patient).Error; err != nil {
return err
}
return nil
}
Associations
Associations 和 Preload 的差別
- 如果是要拿某一 Model 關聯到的另一個 Model 的資料,使用
Association
- User has many Products,使用 Association 可以把此 User 擁有的所有 Products 撈出來回傳-回傳的是 Products
- Association 操作的就是 Join Table
- 如果是要拿某一個 Model,但希望把相關連的 Model 的資料嵌入在原本的 Model 中,使用
Preload
- User has many Products,使用 Preload 可以把此 User 擁有的所有 Products 撈出來放到 User struct 中,回傳的會是帶有 Product 資料的 User -回傳的是 User(帶有 Product 資料在內)
Find Associations
- 假設 Product has many Categories
- 使用 association 最終會得到和此 Product 關聯的所有「Categories」
- 在 model.Product 中要記得放入 PK(
productID
) - 找到 Association 後使用
Find
找出所有和該 Product 關聯的所有 Categories
// database
// 使用 association 最終會得到和此 Product 關聯的所有「Categories」
func (d *GormDatabase) FindAssociationCategories(productID uuid.UUID) ([]*model.Category, error) {
var categories []*model.Category
// model.Product 中要記得帶入 pk
associations := d.DB.Model(&model.Product{ID: productID}).Association("Categories")
if err := associations.Find(&categories); err != nil {
return nil, err
}
return categories, nil
}
// API:要帶入 ProductID(PK)
categories, err := s.db.FindAssociationCategories(product.ID)
可以搭配 where condition
使用,但要注意在 model.Product
中仍然要放入 PK(aka, productID
)因為這是 JOIN 時會用到的 ID,只在 where 中使用 productId
將找不到資料:
// database
// 找出和此 Product 關聯的所有 Categories,且此 Categories 的條件可以透過 where 加以限制
func (d *GormDatabase) FindAssociationCategoriesWithConditions(ID uuid.UUID, conditions ...interface{}) ([]*model.Category, error) {
var categories []*model.Category
// model.Product 中要記得帶入 pk
tx := d.DB.Model(&model.Product{ID: ID})
// 這裡的 Where 會套用到 Category 身上
if len(conditions) == 1 {
tx.Where(conditions[0])
} else if len(conditions) > 1 {
tx.Where(conditions[0], conditions[1:]...)
}
if err := tx.Association("Categories").Find(&categories); err != nil {
return nil, err
}
return categories, nil
}
// API:需要帶入 ProductID 和希望搜尋的 Category 條件
categories, err := s.db.FindAssociationCategoriesWithConditions(product.ID, model.Category{
Name: "Category 4",
})
這裡使用 where
的條件會套用到 categories
上
Append Associations
- 假設 Product has many Categories
- Append Associations 指的是把新的 Categories 建立後並與 Product 建立關聯
db.Model(&M)
中的參數 model 記得要帶入的是 pointer
// database
// 新增的 category 都會和帶入的 Product ID 建立關聯
func (d *GormDatabase) AppendAssociationCategory(ProductID uuid.UUID, category *model.Category) error {
return d.DB.Model(&model.Product{ID: ProductID}).Association("Categories").Append(category)
}
func (d *GormDatabase) AppendAssociationCategories(ProductID uuid.UUID, categories []*model.Category) error {
return d.DB.Model(&model.Product{ID: ProductID}).Association("Categories").Append(categories)
}
// API
err := s.db.AppendAssociationCategories(product.ID, []*model.Category{
{Name: "Category 5"},
{Name: "Category 6"},
})
Replace Associations
- 假設 Product has many Categories
- replace association = clear associations + append new associations
- replace association 則會把所有原本和 Product 相關連的 Associations 移除(reference),建立新的 categories 後,把新的 categories 和提供的 Product ID 建立關聯
replace
時會把所有原本和 Product 相關連的 「Associations (reference)」 移除,並不會移除 categories table 中已經被建立的資料(object)。- 如果是 has and belongs to many 的情況,會移除的是也就是 Join Table
product_categories
中的資料 - 如果是 User has many Products, Products belongs to User 的情況,會移除的是 Product Table 中對應的 userID,也就是移除關聯但不會把舊有的 product 資料刪除
// database
func (d *GormDatabase) ReplaceAssociationCategories(ProductID uuid.UUID, categories []*model.Category) error {
return d.DB.Model(&model.Product{ID: ProductID}).Association("Categories").Replace(categories)
}
// API
s.db.ReplaceAssociationCategories(product.ID, []*model.Category{
{Name: "Category 7"},
{Name: "Category 8"},
})
Delete Association
- 假設 Product has many Categories
- Delete Association 時,需要把要刪除的 categoryID 放入 categories slice 中
- 只會刪除 Product 和 Categories 之間的「關聯(reference)」,不會把 Categories 的資料(object)給刪除
// database
func (d *GormDatabase) DeleteAssociationCategories(productID uuid.UUID, categories []*model.Category) error {
return d.DB.Model(&model.Product{ID: productID}).Association("Categories").Delete(categories)
}
// API:把要刪除的 category ID 放入 categories slice 中
s.db.DeleteAssociationCategories(product.ID, []*model.Category{
{ID: "3da18909-bde4-43b4-ab13-7a3fb7eceb37"},
{ID: "fe8db119-5944-45df-84ec-7371393051fb"},
})
Clear Association
- 假設 Product has many Categories
- 直接把所有與特定 ProductID 相關連的 categories reference 都刪除,不會刪除 table categories 本身的資料
// database
func (d *GormDatabase) ClearAssociationCategories(productID uuid.UUID) error {
return d.DB.Model(&model.Product{ID: productID}).Association("Categories").Clear()
}
// API
err := s.db.ClearAssociationCategories(product.ID)
Preload
- 使用
db.Preload(clause.Associations)
可以撈出所有相關聯的欄位
// Model Struct
type User struct {
ID
Languages []Language
}
type Language struct {
ID
Name
}