How to validate a belongs-to relationship when creating record with gorm

Viewed 970

I have the following models

type PrivateGormModel struct {
    ID        uint       `gorm:"primary_key" json:"id"`
    CreatedAt time.Time  `json:"-"`
    UpdatedAt time.Time  `json:"-"`
    DeletedAt *time.Time `json:"-"`
}

type Employee struct {
    PrivateGormModel
    Person          `gorm:"embedded" json:"person,omitempty"`
    Contact         `gorm:"embedded" json:"contact,omitempty"`
    Address         `gorm:"embedded" json:"address,omitempty"`
    AltContact      `gorm:"embedded" json:"privateContact,omitempty"`
    BankAccount     `gorm:"embedded" json:"bankAccount,omitempty"`
    EmployeeGroupID uint `json:"groupID"`
    EmployeeGroup   `json:"group"`
    EmployeeRoleID  uint `json:"roleID"`
    EmployeeRole    `json:"role"`
}

func (e Employee) Validate() error {
    return validation.ValidateStruct(&e,
        validation.Field(&e.Person, validation.Required),
        validation.Field(&e.Contact),
        validation.Field(&e.Address),
        validation.Field(&e.AltContact),
        validation.Field(&e.BankAccount),
        validation.Field(&e.EmployeeGroup),
        validation.Field(&e.EmployeeRole),
    )
}

type EmployeeGroup struct {
    PrivateGormModel
    Title string `json:"title" gorm:"primaryKey;unique"`
}

func (e EmployeeGroup) Validate() error {
    return validation.ValidateStruct(&e,
        validation.Field(&e.Title, validation.Required, validation.Length(1, 32), validation.Match(regexp.MustCompile(`^[a-zA-Z0-9_ ]*$`))),
    )
}

type EmployeeRole struct {
    PrivateGormModel
    Title string `json:"title" gorm:"primaryKey;unique"`
}

func (e EmployeeRole) Validate() error {
    return validation.ValidateStruct(&e,
        validation.Field(&e.Title, validation.Required, validation.Length(1, 32), validation.Match(regexp.MustCompile(`^[a-zA-Z0-9_ ]*$`))),
    )
}

My employee group and employee role are both just a struct with a gorm model and a title of type string. I have a role with id 1 in the database an a group with id 1. This is the handler that creates the employee

func CreateEmployee(db *database.Database) fiber.Handler {
    return func(c *fiber.Ctx) error {
        employee := new(model.Employee)

        if err := c.BodyParser(employee); err != nil {
            fmt.Printf("%v", err)
            return c.JSON(myResponse.ParsingError())
        }

        // if err := employee.Validate(); err != nil {
        //  return c.JSON(myResponse.ValidationError(err))
        // }

        if result := db.Omit("EmployeeRole.*").Omit("EmployeeGroup.*").Create(&employee); result.Error != nil {
            return c.JSON(myResponse.RecordCreateError())
        }

        return c.JSON(myResponse.RecordCreateSuccess(employee))
    }
}

I parse the incomming JSON into the model which looks like this and I try to create it in the database

{
    "Person":{
        "Initials":"",
        "FirstName":"",
        "MiddleName":"",
        "LastName":"",
        "DateOfBirth":"",
        "Language":""
    },
    "Address":{
        "Country":"",
        "Zip":"",
        "Number":"0",
        "Addition":"",
        "Street":"",
        "State":"",
        "City":""
    },
    "Contact":{
        "Tel":"",
        "Mail":"",
        "URL":""
    },
    "BankAccount":{
        "Bank":"",
        "BIC":"",
        "IBAN":"",
        "AccountHolder":"",
        "Establishment":""
    },
    "EmployeeRoleID":1,
    "EmployeeRole":{
        "Title":"Test"
    },
    "EmployeeGroupID":1,
    "EmployeeGroup":{
        "Title":"Test"
    }
}

Which gives me the following response

{
    "data": {
        "id": 7,
        "person": {
            "initials": "",
            "firstName": "",
            "middleName": "",
            "lastName": "",
            "dateOfBirth": "2021-01-05T11:14:38+01:00",
            "language": ""
        },
        "contact": {
            "tel": "",
            "mail": "",
            "url": ""
        },
        "address": {
            "country": "",
            "zip": "",
            "number": "0",
            "addition": "",
            "street": "",
            "state": "",
            "city": ""
        },
        "privateContact": {
            "tel": "",
            "mail": "",
            "url": ""
        },
        "bankAccount": {
            "bank": "",
            "bic": "",
            "iban": "",
            "accountHolder": "",
            "establishment": ""
        },
        "groupID": 0,
        "group": {
            "id": 0,
            "title": ""
        },
        "roleID": 0,
        "role": {
            "id": 0,
            "title": ""
        }
    },
    "message": "record created successfully",
    "status": "success"
}

Which gives me the following response even if the the role record with ID 1 and the group record with ID 1 does not exist. It the role or group does not exist it should give me a error and not create the record.

1 Answers

It's not obvious how you have set up your relationships since it seems you're using anonymous embedded structs for both EmployeeGroup and EmployeeRole, and you haven't included the code for those. I am going to assume you have set that up correctly and that gorm is happy to deal with relationships involving anonymous embedded structs.

I am also going to assume you meant a BelongsTo relationship, as otherwise where will you put the foreign key to link the Employee table with either the Role or Group tables? It's clear that the foreign keys aren't in either of these last two.

So then you have gotten an Employee struct, when an invalid GroupID or RoleID is given you have two options: either reject the operation when the IDs don't exist, or create a new Role/Group with the IDs given. The first one is the more sane and usual way of handling things, but gorm can do either.

For the first, you can do the following if you have foreign key checks in your DB:

// First make sure that EmployeeRoleID and EmployeeGroupID are set 
err := db.Omit("EmployeeRole", "EmployeeGroup").Create(employee).Error

If the EmployeeGroup.ID or EmployeeRole.ID do not exist, a foreign key violation will occur and you will get an error. You could inspect the error and deduce it's to do with the foreign keys, and then return an appropriate API-level error.

You may find this error checking a bit cumbersome depending on the DB you're using. In all honesty, it's extremely common to fire off a bunch of extra relation validation queries before saving an entity, so don't be surprised about having to do it in this case.

On the other hand, if you want to save a new Role and Group every time, you can remove the Omit call, make sure each ID is zero, and then call Create. Gorm will save the Role and Group, given them new IDs, and then save the link to these new IDs in the employee record.


EDIT

I tried to run your code, and found out a few problems:

  • First, your input JSON has all TitleCased key names, but the model struct expects a lot of them to be lowercased. You need to decide one or the other and stick to it, or marshaling and unmarshaling won't work.
  • Same applies for EmployeeRoleID/EmployeeGroupID vs groupID/roleID. Have one version of names in JSON, and one version in the Go struct (they can also all be the same TitleCased version, if you dropped the json:"blah" tags).
  • Gorm doesn't like embedded anonymous structs for related entities, it doesn't create foreign keys for them and creates extraneous fields in the main table, don't do it.
  • You've used the name of Role and Group as part of a composite primary key, but this breaks the keying because you'd need two fields in the main struct to make it work, e.g. EmployeeGroupID and EmployeeGroupName, which defeats the purpose of having a separate entity. If you want to enforce uniqueness in the name add a unique index instead.
  • Turns out that Omit("Relation.*") only works for many-to-many relations. For belongs-to the thing to do is instead to fill in the RelationID field in the main struct, and omit the relation normally with Omit("Relation").

Here's a simplified version of your model that works:

type Employee struct {
    PrivateGormModel
    Person  `gorm:"embedded" json:"Person"`
    // ...
    RoleID  uint
    Role    EmployeeRole `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
}

type EmployeeRole struct {
    PrivateGormModel
    Title string `gorm:"uniqueIndex"`
}

Here's a test case that shows how it works, assuming there's a configured DB *gorm.DB:

package main

import (
    "encoding/json"
    "testing"

    "github.com/stretchr/testify/require"
)

var inJSON = `{
    "Person": {
        "FirstName": "Test"
    },
    "RoleID": 1
}`

func TestGORM(t *testing.T) {
    require := require.New(t)
    require.NoError(DB.Migrator().DropTable(&Employee{}, &EmployeeRole{}))
    require.NoError(DB.Migrator().AutoMigrate(&Employee{}, &EmployeeRole{}))
    emp := Employee{}
    json.Unmarshal([]byte(inJSON), &emp)

    // create the role to simulate that it exists
    role := EmployeeRole{PrivateGormModel{ID: 1}, "Test"}
    require.NoError(DB.Create(&role).Error)

    // avoid re-saving emp.Role
    require.NoError(DB.Omit("Role").Create(&emp).Error)

    // if instead the RoleID doesn't exist
    emp.RoleID = 5
    require.Error(DB.Create(&emp).Error)
}
Related