lewisdale.dev/src/blog/posts/2024/5/learning-go-day-10.md
Lewis Dale 70f83622e3
All checks were successful
Build and copy to prod / build-and-copy (push) Successful in 3m2s
11th Weblogpomo post
2024-05-09 08:03:49 +01:00

5.6 KiB

title date tags excerpt
Learning Go: Day Ten 2024-05-11T08:00:00.0Z
learning
go
More databases, enums, and all that sort of fun stuff.

Yesterday, I got connected to a SQLite database, and got my first table created. I'm going to carry on with that thread today and do some more database work.

Modifying the Sites table

Yesterday, I mentioned that really my Sites should just be identified by the URL, rather than having an auto-incremented ID. There are some caveats here, and in theory I should really maintain a unique UUID or something, but this is a very simple app and I can always change it later if I need to.

So, firstly I need to change my table definition. Because this app has no data, I'm just going to delete the existing database off my server, and have this be the initial table definition. Later on I'll need to use some form of migration to do these changes to prevent data loss:

// sites/sites.go
const createSitesTable = `CREATE TABLE IF NOT EXISTS sites (
url TEXT NOT NULL PRIMARY KEY,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
name TEXT NOT NULL
);`

type Site struct {
	created_at uint64
	Name       string
	Url        string
}

That's more-or-less the biggest change I need to make. The nice thing about this is that it allows me to really simplify the Save function by using an upsert1 instead:

const upsertQuery = `INSERT INTO sites (url, name) VALUES (?, ?)
ON CONFLICT (url) DO UPDATE
SET
	name = excluded.name
`

func (s *Site) Save(db *sql.DB) {
	db.Exec(upsertQuery, s.Url, s.Name)
}

Pingu! Wait, no

The next model I want to define is a log of all of the "pings" I've made to a site. The heading from this section comes from the fact that the file I've created is called ping.go2. I figured a ping would need three key pieces of data:

  • The site it's pinging
  • The time it occurs
  • Something that tells me what the result was

The struct definition for this was fairly straightforward:

// ping/ping.go
type Ping struct {
	Site      sites.Site
	Timestamp string
	Status    ???
}

Enums in Go

The Status field is the only one I wasn't certain about. In other languages, I might use an Enum for this, but Go doesn't have Enums! Instead, you can use grouped constants, which are immutable, and the iota keyword to auto-increment a value. I've defined my Status enum using this:

type Status int16

const (
	Success Status = iota
	Failure
)

If I were to print the values, I'd get something like:

fmt.Println("%d", Success) // 0
fmt.Println("%d", Failure) // 1

And now I can reference the Status type in my Ping struct:

type Ping struct {
	Site      sites.Site
	Timestamp string
	Status    Status
}

Creating the tables

The enum workaround isn't ideal, but it actually maps really nicely to SQLite, which also doesn't have enums. Instead, I have to create a table just for the status type, and then seed the values.

const createQuery = `CREATE TABLE IF NOT EXISTS statuses (
	id INTEGER PRIMARY KEY,
	name TEXT NOT NULL
);`

const seedStatusQuery = `INSERT INTO statuses (id, name) VALUES (?, ?)
ON CONFLICT (id) DO NOTHING;`

func CreateTable(db *sql.DB) {
	if _, err := db.Exec(createQuery); err != nil {
		panic(err)
	}

	seedStatuses(db)
}

func seedStatuses(db *sql.DB) {
	if _, err := db.Exec(seedStatusQuery, Success, "Success"); err != nil {
		panic(err)
	}
	if _, err := db.Exec(seedStatusQuery, Failure, "Failure"); err != nil {
		panic(err)
	}
}

And then finally, I can create my table to store the ping log:

const createQuery = `CREATE TABLE IF NOT EXISTS statuses (
	id INTEGER PRIMARY KEY,
	name TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS ping (
	id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
	site TEXT NOT NULL,
	timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
	status INTEGER NOT NULL,
	FOREIGN KEY (site) REFERENCES sites(url),
	FOREIGN KEY (status) REFERENCES statuses(id)
);`

So to break that down, I've got 4 fields, two of which are auto-generated on inserting a row. The other two are just foreign key references to the statuses and sites tables. That makes saving a ping fairly easy:

const saveQuery = `INSERT INTO ping (site, status) VALUES (?, ?);`

func (p *Ping) Save(db *sql.DB) {
	db.Exec(saveQuery, p.Site.Url, p.Status)
}

Finally, because SQLite doesn't have foreign key constraints enabled by default, I need to turn them on using a PRAGMA statement when my application starts up:

// main.go

func main() {
    // ...

	db.Exec("PRAGMA foreign_keys = ON;")
    sites.CreateTable(db)
	ping.CreateTable(db)
}

Why not use an ORM?

I could use an ORM3 and there are some fairly popular ones out there, like GORM. I've decided not to for a couple of reasons. Partly because I'm doing this to learn, and while I'm already fairly comfortable with SQL, understanding the underlying connection is useful. The other reason is that I don't really like how much ORMs obfuscate the database, they're usually only good for the simplest of queries anyway - it's often much more efficient to write complex queries by hand.

That's Day 10 wrapped up. Next up, I'll be working on actually populating some data in the database.


  1. Maybe I'm alone here, but finding out that this was possible in SQL was such a game-changer for me ↩︎

  2. Any similarities to mischievous claymation penguins, living or dead, is purely coincidental ↩︎

  3. Object Relational Mapper, that sits between the software and the database and provides handy functions for easily creating data models, tables, and the relationships between them ↩︎