Using GORM – Part 1: Introduction
In today’s software development landscape, Object-Relational Mapping (ORM) has become an essential technique for managing relational databases. ORM allows developers to work with databases using an object-oriented approach without having to write SQL queries. ORM tools provide a set of methods that abstract away the complexity of dealing with databases and make it easier to perform CRUD operations. When it comes to the Go programming language, ORM tools have gained popularity due to the language’s simplicity, efficiency, and emphasis on concurrency. While Go’s standard library doesn’t include an ORM tool, third-party libraries such as GORM, XORM, and QBS provide a high level of abstraction that allows developers to write idiomatic Go code without dealing with the low-level details of database interaction. In this blog post, we’ll explore some of the best practices and pitfalls of using GORM.
Note: For simplicity, I will write the examples in this post as unit tests, that can be copy-pasted into your IDE and executed individually.
Let’s start with the following type definitions:
1 2 3 4 5 6 7 8 9 10 11 12 13 | type User struct { UUID string `gorm:"primary_key"` FirstName string LastName string } type Bill struct { UUID string `gorm:"primary_key"` UserID string User *User Amount int64 DueDate time.Time } |
Let’s start by creating our tables:
1 2 3 4 5 6 7 8 9 10 11 12 13 | // See: https://gorm.io/docs/connecting_to_the_database.html func connectToDB() (*gorm.DB, error) { dsn := "host=localhost user=gorm password=gorm dbname=test_db port=9920 sslmode=disable TimeZone=Etc/UTC" return gorm.Open(postgres.Open(dsn), &gorm.Config{}) } func TestCreateTables(t *testing.T) { db, err := connectToDB() require.NoError(t, err) err = db.Debug().Migrator().CreateTable(User{}, Bill{}) require.NoError(t, err) } |
Looking at my Postgres database, I can see now two tables with the following definition:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | test_db=# \d+ users Table "public.users" Column | Type | Collation | Nullable | Default | Storage | Compression | Stats target | Description ------------+------+-----------+----------+---------+----------+-------------+--------------+------------- uuid | text | | not null | | extended | | | first_name | text | | | | extended | | | last_name | text | | | | extended | | | Indexes: "users_pkey" PRIMARY KEY, btree (uuid) Referenced by: TABLE "bills" CONSTRAINT "fk_bills_user" FOREIGN KEY (user_id) REFERENCES users(uuid) Access method: heap test_db=# \d+ bills Table "public.bills" Column | Type | Collation | Nullable | Default | Storage | Compression | Stats target | Description ----------+--------------------------+-----------+----------+---------+----------+-------------+--------------+------------- uuid | text | | not null | | extended | | | user_id | text | | | | extended | | | amount | bigint | | | | plain | | | due_date | timestamp with time zone | | | | plain | | | Indexes: "bills_pkey" PRIMARY KEY, btree (uuid) Foreign-key constraints: "fk_bills_user" FOREIGN KEY (user_id) REFERENCES users(uuid) Access method: heap |
As you can see, GORM is smart enough to create a foreign-key constraint and link the user_id from bills to the uuid in the users’ table. You can use different tags to customize the table creation, add indexes and other interesting things, but I won’t touch on them in this blog post.
Another interesting thing to note is that you can always add Debug() (as I had in my example) to your expression, and GORM will dump the actual SQL query that it generated. In our example:
1 2 | [12.888ms] [ROWS:0] CREATE TABLE "users" ("uuid" text,"first_name" text,"last_name" text,PRIMARY KEY ("uuid")) [4.935ms] [ROWS:0] CREATE TABLE "bills" ("uuid" text,"user_id" text,"amount" BIGINT,"due_date" timestamptz,PRIMARY KEY ("uuid"),CONSTRAINT "fk_bills_user" FOREIGN KEY ("user_id") REFERENCES "users"("uuid")) |
Now, that I have the tables ready, I can start creating records. Having to set the uuid manually each time I want to create an item is an annoyance, and as it’s not part of my business logic, I don’t really want to deal with it each time π
For that, GORM allows you to use different hooks that can simplify your life. In our example, we can use the BeforeCreate hook to assign a UUID to the records:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | func (u *User) BeforeCreate(tx *gorm.DB) error { if u.UUID == "" { u.UUID = generateUUID() } return nil } func (b *Bill) BeforeCreate(tx *gorm.DB) error { if b.UUID == "" { b.UUID = generateUUID() } return nil } func TestCreateItems(t *testing.T) { db, err := connectToDB() require.NoError(t, err) user := &User{FirstName: "Charlie", LastName: "Chaplin"} err = db.Create(user).Error require.NoError(t, err) bill := &Bill{UserID: user.UUID, Amount: 30, DueDate: time.Now().Add(3 * 24 * time.Hour)} err = db.Create(bill).Error require.NoError(t, err) } |
The generated SQL queries look like this:
1 2 | [3.469ms] [ROWS:1] INSERT INTO "users" ("uuid","first_name","last_name") VALUES ('89c3d790-2712-4321-ae07-7fa9b5b112d7','Charlie','Chaplin') [2.868ms] [ROWS:1] INSERT INTO "bills" ("uuid","user_id","amount","due_date") VALUES ('a8d45bd5-fabe-4856-8f94-f95adb8faf21','89c3d790-2712-4321-ae07-7fa9b5b112d7',30,'2023-03-07 01:13:43.243') |
Now, we can also read it from the database, using different criteria.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | func TestReadItems(t *testing.T) { db, err := connectToDB() require.NoError(t, err) var user *User err = db.Where(User{FirstName: "Charlie"}).First(&user).Error require.NoError(t, err) require.NotNil(t, user) var bills []*Bill err = db.Where(Bill{UserID: user.UUID}).Find(&bills).Error require.NoError(t, err) assert.Len(t, bills, 1) } |
We got the user record and the bills for that user (in this case, just one). The generated SQL queries are:
1 2 | [3.645ms] [ROWS:1] SELECT * FROM "users" WHERE "users"."first_name" = 'Charlie' ORDER BY "users"."uuid" LIMIT 1 [1.256ms] [ROWS:1] SELECT * FROM "bills" WHERE "bills"."user_id" = '89c3d790-2712-4321-ae07-7fa9b5b112d7' |
Unfortunately, GORM is not smart enough to remember that we have the user object and link it to the bill object, so bills[0].User will be nil. For situations like this, we can use Preload and specify the fields we are interested in:
1 2 3 4 5 6 7 8 9 10 11 12 | func TestReadBillWithUserPreloaded(t *testing.T) { db, err := connectToDB() require.NoError(t, err) userID := "1916fc8f-fdb8-4d41-9de4-ec30dc6b9300" var bills []*Bill err = db.Preload("User").Where("user_id = ?", userID).Find(&bills).Error require.NoError(t, err) require.Len(t, bills, 1) require.NotNil(t, bills[0].User) assert.Equal(t, "Charlie", bills[0].User.FirstName) } |
This will generate the following SQL queries:
1 2 | [1.462ms] [ROWS:1] SELECT * FROM "users" WHERE "users"."uuid" = '1916fc8f-fdb8-4d41-9de4-ec30dc6b9300' [3.998ms] [ROWS:1] SELECT * FROM "bills" WHERE user_id = '1916fc8f-fdb8-4d41-9de4-ec30dc6b9300' |
There are many types of queries you can run, including Select, Joins, Orders, and many more. You can find a full list in the official GORM documentation.
Now, let’s talk about updates. Let’s say I want to change the bill amount to 0 and the due date to today.
1 2 3 4 5 6 7 8 9 10 11 | func TestUpdateBill(t *testing.T) { db, err := connectToDB() require.NoError(t, err) billID := "172e4a83-8fdd-452c-b4f2-7fc53c937e50" err = db. Model(Bill{UUID: billID}). Updates(Bill{Amount: 0, DueDate: time.Now()}). Error require.NoError(t, err) } |
This will generate the following SQL query:
1 | [4.160ms] [ROWS:1] UPDATE "bills" SET "due_date"='2023-03-04 02:27:10.685' WHERE "uuid" = '172e4a83-8fdd-452c-b4f2-7fc53c937e50' |
OOPS! What happened? Why I don’t have an update clause for the bill amount? The reason is that when passing a struct to GORM, it doesn’t know if you explicitly specified the “Amount” to be zero, or you haven’t specified it at all (zero is the default value for integers). So, GORM will just ignore it.
We can overcome this by making it more explicit:
1 2 3 4 5 6 7 8 9 10 11 12 | func TestUpdateBill(t *testing.T) { db, err := connectToDB() require.NoError(t, err) billID := "172e4a83-8fdd-452c-b4f2-7fc53c937e50" err = db. Model(Bill{UUID: billID}). Updates(Bill{DueDate: time.Now()}). UpdateColumn("amount", 0). Error require.NoError(t, err) } |
Now we (almost) got what we wanted:
1 2 | [4.839ms] [ROWS:1] UPDATE "bills" SET "due_date"='2023-03-04 02:29:50.066' WHERE "uuid" = '172e4a83-8fdd-452c-b4f2-7fc53c937e50' [3.154ms] [ROWS:1] UPDATE "bills" SET "amount"=0 WHERE "uuid" = '172e4a83-8fdd-452c-b4f2-7fc53c937e50' AND "uuid" = '172e4a83-8fdd-452c-b4f2-7fc53c937e50' |
Apparently, GORM doesn’t handle well this kind of mixture, so we should do something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | func TestUpdateBill(t *testing.T) { db, err := connectToDB() require.NoError(t, err) billID := "172e4a83-8fdd-452c-b4f2-7fc53c937e50" err = db. Model(Bill{UUID: billID}). UpdateColumns(map[string]interface{}{ "amount": 0, "due_date": time.Now(), }). Error require.NoError(t, err) } |
Ok. Now we got what we want:
1 | [3.927ms] [ROWS:1] UPDATE "bills" SET "amount"=0,"due_date"='2023-03-04 02:31:57.576' WHERE "uuid" = '172e4a83-8fdd-452c-b4f2-7fc53c937e50' |
Getting familiar with GORM and its different nuances is really important, especially when dealing with complex objects.
In the next part, we will see some of the painful pitfalls of GORM which look innocent at first sight but can do real damage to your data.
– Alexander