跳至主要内容

[Note] GORM 使用範例

TL;DR

db.
Table("table_name"). // Model(&model{})
Select("select syntax").
Group("field_names").
Order("field_name").
Find(&model{}) // Rows()
  • methods 可以分成 chain methodsfinisher methodsnew session method

一對多(has many, belongs to)

情境

一個 User 有多個 Product,而一個 Product 只屬於一個 User。

Model 定義

User has many products

hasMany.go @ gist

  • 使用 Products []Product 表示 User 有很多 Product
  • 如果沒有 Products 不希望顯示空陣列,可以在 json tag 中使用 omitempty
type User struct {
Name string `json:"name"`
Products []Product `json:"products"`
}
type Product struct {
Name string `json:"name"`
UserID uuid.UUID `json:"userId"`
User User `json:"user"`
}

Product belong to User

Product model 需要

  • UserID 作為 foreign key(預設)
  • User Model 表示 belongs to,型別使用 *User 搭配 json struct field tag 的 omitempty 則可以在 Product 沒有 User 撈出資料時不要顯示此欄位
type Product struct {
Name string `json:"name"`
UserID uuid.UUID `json:"userId"`
User *User `json:"user,omitempty"`
}

CRUD 使用

Read: 使用 Preload 將 User 中關聯的 Product 取出

  • 使用 db.Preload(clause.Associations) 可以一此把所有和 User 相關連的欄位(即,Product)一併撈出
  • 使用 db.Preload("Products" 則可以指定要一併撈出的欄位名稱)
// database/user.go
func (d *GormDatabase) GetUserByID(userID uuid.UUID) (*model.User, error) {
user := &model.User{}
if err := d.DB.Preload(clause.Associations).Take(&user, userID).Error; err != nil {
return nil, err
}

return user, nil
}

取得的結果會像這樣:

{
"name": "aaron",
"products": [
{
"name": "Product 2",
"price": "200",
"sn": 2,
"comment": "Second Product",
"userId": "61ecf82b-8385-4606-83e9-70e13a4cdb5d"
},
{
"name": "Product 3",
"price": "300",
"sn": 3,
"comment": "Third Product",
"userId": "61ecf82b-8385-4606-83e9-70e13a4cdb5d"
}
]
}

Create

建立資料的時候,只需要把 User 中的 Product 帶入後即會自動建立和 User 相關連的 Product:

// database/user.go
// CreateUser 會自動把 nested 的 Product 一併建立起來
func (d *GormDatabase) CreateUser(user *model.User) error {
return d.DB.Create(user).Error
}

使用時直接把和 User 有關聯了 Products 放入即可:

// api/user.go
func TestUserHasManyProducts() {
assertWithT := assert.New(s.T())
mockUser := mock.GetUser()
mockProducts := mock.GetProductsWithoutUser()
// 直接把和 User 有關聯了 Products 放入即可
mockUser.Products = []model.Product{
mockProducts[0],
mockProducts[1],
}

/* create patient with contacts */
assertWithT.NoError(s.db.CreateUser(&mockUser))
}

Update

使用 Update 要小心 zero value 不會被更新的。

UpdateUser 時,當 user 中的 Product(下面的 id 是指 Product 的 ID):

  • 沒有帶 id 時會自動建立新的
  • 有帶 id 但此 id 不存在時,會自動建立新的
  • 有帶 id 且此 id 存在時,會更新此 product 的 UserID
// database/user.go
func (d *GormDatabase) UpdateUser(user *model.User) error {
return d.DB.Updates(user).Error
}

如果想要在更新 User 時一併更新 nested 的 Product 可以自己判斷:

  • 使用 db.Updates() 的話,User 或 Contact 中的 zero value 都不會更新到
  • 使用 db.Save() 的話,雖然所有 zero value 都會處理得到,但在 save contact 的時候,Contact 欄位中一定要提供 UserID,不然
  • 會出現 foreign key error
// database/user.go
// SaveUserAndProduct 會更新所有欄位(包含 zero value)
func (d *GormDatabase) SaveUserAndProduct(userToBeUpdate *model.User) (*model.User, error) {
// 最外層用 Transaction 包起來
err := d.DB.Transaction(func(tx *gorm.DB) error {

// 移除 products(原本有,後來沒有的,要刪除)
user := &model.User{}
if err := d.DB.Preload(clause.Associations).Take(user, userToBeUpdate.ID).Error; err != nil {
return err
}
productToBeDelete := filter(user.Products, func(c model.Product) bool {
return !contains(userToBeUpdate.Products, c)
})
if len(productToBeDelete) > 0 {
if err := d.DB.Delete(&productToBeDelete).Error; err != nil {
return err
}
}

// 更新 products(原本有,後來也有的,要更新)
user = &model.User{}
if err := d.DB.Preload(clause.Associations).Take(user, userToBeUpdate.ID).Error; err != nil {
return err
}
productToBeUpdate := filter(userToBeUpdate.Products, func(c model.Product) bool {
return contains(user.Products, c)
})
if len(productToBeUpdate) > 0 {
for _, c := range productToBeUpdate {
err := d.DB.Save(c).Error
if err != nil {
return err
}
}
}

// 更新 user 並新增不存在的 product
// Product 中一定樣提供 UserID 才能成功建立 Product
if err := d.DB.Save(userToBeUpdate).Error; err != nil {
return err
}

return nil
})

if err != nil {
return nil, err
}

updatedUser := &model.User{}
if err := d.DB.Preload(clause.Associations).Take(updatedUser, userToBeUpdate.ID).Error; err != nil {
return nil, err
}

return updatedUser, nil
}

// utils
func filter(searchElement []model.Contact, handler func(contact model.Contact) bool) []model.Contact {
n := 0
for _, element := range searchElement {
if handler(element) {
searchElement[n] = element
n++
}
}
return searchElement[:n]
}

func contains(searchElement []model.Contact, target model.Contact) bool {
for _, element := range searchElement {
if element.ID == target.ID {
return true
}
}
return false
}

Delete

要把與 User 關聯的 Product 一併刪除,只需要在刪除的方法中加上 db.Select(clause.Associations)

  • 使用 db.Select(clause.Associations) 會直接把所有和 User 關聯的 nested model 一併刪除
  • 使用 db.Select("Product") 則只會刪除和 User 關聯的 Product Model
  • 若希望保留相關連的 Model 而不要一併刪除,則不要使用 db.Select()
// database/user.go
func (d *GormDatabase) DeleteUserByID(userID uuid.UUID) error {
return d.DB.Select(clause.Associations).Delete(&model.User{
ID: userID,
}).Error
}

Method Chaining

keywords: method chaining, immediate methods, scope

Method Chaining @ GORM

把條件填入 SQL

可以使用 Method Chaining 的方式串連多個 where 條件,這些條件只有在 immediate method 被呼叫時才會產收 SQL query 並傳送到 database 中被執行。immediate method 多是 CRUD 時使用,例如:Create, First, Find, Take, Save, UpdateXXX, Delete, Scan, Row, `Rows。

func (d *GormDatabase) GetActivitiesGroupByDate(begin int64, end int64) (
[]model.ActivityResp, error,
) {
var activities []model.ActivityDaily

// tx := d.DB.Where("")
tx := d.DB.Table("activities"). // 也可以寫 tx := d.DB.Model(&model.Activity{})
Select("patient_id, date_trunc('day', TO_TIMESTAMP(recorded_at)) AS date, SUM(count) as count")

if begin != 0 {
tx.Where("recorded_at >= ?", begin)
}

if end != 0 {
tx.Where("recorded_at <= ?", end)
}

// start query with finish method "Find"
tx.Group("patient_id, date").Find(&activities)

return activitiesResp, nil
}

把條件直接帶入 ORM

  • GetProducts 方法參數 condition 可以接收許多的 condition
  • 搭配 .Where 的 method chaining
  • .Where 第一個參數一定要給;第二個餐數則可以用陣列展開,因此根據條件數目(len(conditions))進行判斷
// ./database/product
// https://github.com/gotify/server/blob/master/database/user.go
func (d *GormDatabase) GetProducts(conditions ...interface{}) ([]*model.Product, error) {
var products []*model.Product

tx := d.DB.Model(&model.Product{})

if len(condition) == 1 {
tx.Where(conditions[0])
} else if len(condition) > 1 {
tx.Where(conditions[0], conditions[1:]...)
}

if err := tx.Find(&products).Error; err != nil {
return nil, err
}

return products, nil
}

在 API 的地方可以直接對於沒有要查詢的條件可以直接預設值:

// ./runner/product
type ProductQuery struct {
Name string
Price uint
SN uint
Comment string
}

func (r *ProductRunner) GetProducts(
qs ProductQuery,
) ([]*model.Product, error) {

// 如果 qs 中的欄位是 zero value 則不會進行查詢
products, err := r.DB.GetProducts(
&model.Product{
Name: qs.Name,
SN: qs.SN,
Price: qs.Price,
Comment: qs.Comment,
}
)

if err != nil {
return nil, err
}

return products, nil
}

使用時可以直接帶入 struct:

func main() {
// 直接建立 query 的 struct
qs := model.ProductQuery{
SN: 2,
Comment: "first",
}

// 直接把 qs 帶入即可查詢符合條件的資料
products, _ := productRunner.GetProducts(qs)
}

簡易的使用 where 串連條件

如果條件沒有太複雜,也可以直接使用 where 串連:

func getCount(t Tag, db *gorm.DB) (int64, error) {
var count int64
if t.Name != "" {
db = db.Where("name = ?", t.Name)
}

db = db.Where("state = ?", t.State)
err := db.Model(&t).Where("is_del = ?", 0).Count(&count).Error
if err != nil {
return 0, err
}

return count, nil
}