Skip to content

Models

The model package defines the types and helpers for persisting and loading database records through Go structs called models.

Defining a model

A model is a struct that embeds BaseModel and uses the db struct tag to describe its columns. The tag’s first value is the column name, and the remaining values are flags or modifiers:

type Foo struct {
	model.BaseModel

	ID         int                   `db:"id,primary,autoincrement"`
	Name       string                `db:"name,size:100"`
	Slug       string                `db:"slug,unique"`
	Score      int                   `db:"score,index"`
	Note       option.Option[string] `db:"note"`
	ComputedAt time.Time             `db:"computed_at,readonly"`
	Password   string                `db:"-"`
}

Supported tag values

tagdescription
primarythe column is part of the primary key
autoincrementthe primary key is populated by the database on insert
nullablethe column may store NULL (implied by a nullable field type, see below)
readonlythe column is excluded from INSERT and UPDATE statements (used for values computed by the database)
indexcreate an index on the column
uniquecreate a unique index on the column
type:XXXoverride the column’s SQL type (defaults are inferred from the Go field type)
size:NNNset the column size, e.g. size:100 for a VARCHAR(100) (only used when generating migrations)
-skip the field entirely: keep it in memory only

If no db tag is present, the Go field name is used verbatim as the column name. Anonymous (embedded) structs are walked recursively, which is what lets mixins like mixins.Timestamps contribute their columns, and why BaseModel contributes nothing. Fields whose type implements relationship.Relationship are skipped during column collection and are managed separately by the builder package.

The table name is the kebab of the struct name plus a trailing s (Foo becomes foos), unless the model implements Table() string.

Nullable columns

A column is nullable when the Go field type says so, so the nullable tag is only needed on a field that is neither a pointer nor an option.Option[T]:

field typenullable taggenerated column
stringrequiredtable.String("x").Nullable()
stringomittedtable.String("x")
*stringnot neededtable.String("x").Nullable()
option.Option[string]not neededtable.String("x").Nullable()

option.Option[T] is the preferred way to model a nullable column. A pointer field works, but it cannot tell a NULL column apart from a column holding the zero value of the type, and it adds a level of indirection to every use of the field:

type Foo struct {
	model.BaseModel

	Note option.Option[string] `db:"note"`
}

if foo.Note.Valid() {
	// note is present
}

note := foo.Note.OrElse("") // "" whether the column is NULL or empty

The same Option marshals to and unmarshals from JSON, so a model can be returned straight from a handler and still send null for a NULL column instead of flattening it to a zero value. See Option Values for the rest of the API.

option.OfNull converts a pointer into an Option, mapping nil to an invalid one, which is handy when a value arrives as a pointer from somewhere else:

// a request that uses a pointer to tell an absent field from an empty one
type createFooRequest struct {
	Note *string `json:"note"`
}

foo := &Foo{
	Note: option.OfNull(r.Note), // a nil pointer becomes an invalid Option
}

Saving models

Save inserts a new row or updates an existing one depending on whether the model has already been loaded from (or saved to) the database:

foo := &Foo{Name: "test"}
err := model.Save(tx, foo)

Hooks run around saves: a model may implement hooks.BeforeSaver or hooks.AfterSaver, and embedded mixins can contribute them. For example mixins.Timestamps sets created_at and updated_at before every save, and mixins.SoftDelete adds a deleted_at column that hides deleted rows from queries and turns Delete into an update of deleted_at. Its DeletedAt field is an option.Option[time.Time], so an unset deleted_at reads as an invalid Option.

Loading from HTTP requests

modeldi.Register[*Foo] registers a dependency-injection provider that loads a Foo by ID from a request. The ID is resolved from the URL query, a gorilla/mux path variable, or an r.PathValue, in that order. The generated template wires this up in init so handlers can inject the model directly:

type fooRequest struct {
	Foo *models.Foo `inject:"id"`
}

Generating migrations

The migration generator is driven by the same db tags. Place this comment above a model struct and run go generate:

//go:generate spice generate:migration

spice generates a migration that keeps the model’s table in sync with the columns described by its db tags.