Skip to content

Records and struct tags

Insert(...).Rows(records...) and Update(...).SetRecord(rec, ...) read columns and values from structs. gohan does not scan query results into structs; use your driver or a scanning library for that.

How a struct maps to columns

Every exported field is a column, in field order. Unexported fields are ignored. The column name comes from the db tag, then the ch tag; without a tag it is the field name in lower case (not snake case):

type Account struct {
    ID        int64  `db:"id"`
    OwnerName string `db:"owner_name"`
    CreatedAt string // no tag
    internal  string // unexported: ignored
}

cols, err := gohan.RecordColumns(reflect.TypeOf(Account{}))
fmt.Println(cols, err)
// Output:
// [id owner_name createdat] <nil>

A pointer field binds the value it points to, or nil (sent as NULL) when it is nil.

Tag reference

Tag Effect
db:"name" column name; db:"-" excludes the field
ch:"name" same as db, used when there is no db tag
db:"name,auto" column name, and marks the field as auto
auto:"true" marks the field as auto
goqu:"skipinsert", goqu:"skipupdate" mark the field as auto (both: see below)
goqu:"omitnil" skip the field when it is a nil pointer
goqu:"omitempty" skip the field when it holds its type's zero value
grid:"sort", grid:"filter", grid:"search" set Sortable/Filterable/Searchable in the field metadata; no effect on SQL
grid:"auto" marks the field as auto
alias:"x", json:"x", xml:"x" set the metadata Alias (first found in that order); no effect on SQL

Tag values are comma-separated. omitnil and omitempty only work in the goqu tag: in db:"x,omitempty" the option is ignored and the field is still written. Other unrecognized values in the db, ch, grid and goqu tags are kept in the metadata's DbOptions and otherwise ignored.

Auto fields

An auto field holds a value the database generates, such as a serial id or a default timestamp. It is skipped by Rows (always) and by SetRecord (unless WithAutoFields() is passed). It is still listed by RecordColumns.

goqu:"skipinsert" and goqu:"skipupdate" both mark a field as auto, so either one skips the field on insert and on update. This differs from goqu, where each skips only one statement:

type Post struct {
    ID        int64  `db:"id" auto:"true"`
    Title     string `db:"title"`
    CreatedAt string `db:"created_at" goqu:"skipupdate"`
}

cols, err := gohan.InsertColumns(Post{Title: "hello", CreatedAt: "2024-06-01"})
fmt.Println(cols, err)
// Output:
// [title] <nil>

Omitting nil and empty values

goqu:"omitnil" skips a nil pointer field and goqu:"omitempty" skips a zero value, on both insert and update. For Rows, the first record decides which fields are present, and every other record must agree (ErrInconsistentOmit). See INSERT and upsert.

type Profile struct {
    UserID int64   `db:"user_id"`
    Bio    string  `db:"bio" goqu:"omitempty"`
    Avatar *string `db:"avatar" goqu:"omitnil"`
}

sql, args, err := gohan.Insert("profiles").Rows(Profile{UserID: 1}).Build(gohan.Postgres())
// sql: INSERT INTO "profiles" ("user_id") VALUES ($1)
// args: [1]

SkipZeroValues() does the same as omitempty for every field of one SetRecord call.

Embedded structs

The fields of an embedded (anonymous) exported struct are flattened into the parent, in place:

type Timestamps struct {
    CreatedAt time.Time `db:"created_at" auto:"true"`
    UpdatedAt time.Time `db:"updated_at"`
}
type Invoice struct {
    ID    int64  `db:"id" auto:"true"`
    Label string `db:"label"`
    Timestamps
}

cols, err := gohan.RecordColumns(reflect.TypeOf(Invoice{}))
fmt.Println(cols, err)
// Output:
// [id label created_at updated_at] <nil>

time.Time is a struct but is mapped as a single column, because it is registered as a reserved type.

Shapes that could map a value to the wrong column are refused with ErrRecordShape rather than guessed at:

  • an embedded pointer to a struct (*Timestamps);
  • an unexported embedded struct;
  • an embedded struct with a db or ch tag;
  • two mapped fields with the same Go name, including an outer field that shadows an embedded one.

Two fields with the same column name are refused too. The error is currently ErrRecordShape in most layouts and ErrDuplicateColumn when the duplicate comes from an embedded struct that follows the field, so check for both.

type Base struct {
    ID int64 `db:"id"`
}
type WithPointer struct {
    *Base
    Name string `db:"name"`
}
type Duplicate struct {
    Base
    OtherID int64 `db:"id"`
}

_, err := gohan.RecordColumns(reflect.TypeOf(WithPointer{}))
fmt.Println(errors.Is(err, gohan.ErrRecordShape), err)

_, err = gohan.RecordColumns(reflect.TypeOf(Duplicate{}))
fmt.Println(errors.Is(err, gohan.ErrRecordShape) || errors.Is(err, gohan.ErrDuplicateColumn), err)
// Output:
// true gohan: record type has a field shape gohan cannot map safely: embedded pointer field "Base"
// true gohan: record type has a field shape gohan cannot map safely: duplicate field name for field id

Column lists: RecordColumns and InsertColumns

RecordColumns(reflect.Type) returns every column a struct type maps, auto fields included, in field order. It is handy for a select list that matches the struct:

type User struct {
    ID       int64  `db:"id" auto:"true"`
    Name     string `db:"name"`
    Email    string `db:"email"`
    Password string `db:"-"`
}

cols, _ := gohan.RecordColumns(reflect.TypeOf(User{}))
selectList := make([]any, len(cols))
for i, c := range cols {
    selectList[i] = c
}

sql, args, err := gohan.Select(selectList...).From("users").Build(gohan.Postgres())
// sql: SELECT "id", "name", "email" FROM "users"
// args: []

InsertColumns(record) returns the columns an insert of that record would write: auto fields, nil omitnil pointers and zero omitempty values are left out.

Both apply the same shape checks as Rows and SetRecord.

The field package

github.com/oddbit-project/gohan/field holds the struct metadata that gohan uses. field.GetStructMeta(reflect.Type) returns one field.Metadata per mapped field: Name, DbName, Alias, Auto, OmitNil, OmitEmpty, Sortable, Filterable, Searchable, TypeName, Type and DbOptions. Results are cached per type.

type Product struct {
    ID    int64   `db:"id" auto:"true"`
    Name  string  `db:"name" json:"title" grid:"sort,search"`
    Price float64 `db:"price" grid:"sort,filter"`
}

meta, err := field.GetStructMeta(reflect.TypeOf(Product{}))
if err != nil {
    panic(err)
}
for _, m := range meta {
    fmt.Println(m.Name, m.DbName, m.Alias, m.Sortable, m.Filterable, m.Searchable)
}
// Output:
// ID id ID false false false
// Name name title true false true
// Price price Price true true false

The grid flags are metadata only. A listing API can use them as an allowlist for user-chosen sort and filter columns (see Identifiers from request data).

field.GetStructMeta itself does not reject the unsafe shapes listed under Embedded structs; gohan's statement builders and RecordColumns do.

Reserved types

A struct-typed field is normally only a problem when embedded, since embedded structs are flattened. A reserved type is mapped as one column even when embedded. time.Time is reserved by default; register other driver-handled struct types (a custom timestamp or decimal type) with field.AddReservedType, using the type's reflect.Type.String() name:

field.AddReservedType("decimal.Decimal")
fmt.Println(field.IsReservedType("decimal.Decimal"), field.IsReservedType("time.Time"))
// Output:
// true true

Register reserved types during start-up, before any struct that uses them is mapped: metadata is cached per struct type and is not recomputed afterwards.

Errors

Error Cause
ErrInvalidRecord the record is nil, a nil pointer, or not a struct
ErrRecordType Rows records of different struct types
ErrRecordShape an embedded pointer, unexported or tagged embedded struct, two mapped fields with the same Go name, or (in most layouts) two fields with the same column name
ErrDuplicateColumn a column set twice in an UPDATE, or two fields with the same column name when the second comes from an embedded struct
ErrInconsistentOmit Rows records disagree on which omitnil/omitempty fields are present
ErrUnknownField an IncludeFields/ExcludeFields name that matches no field
ErrNoColumns a record with no insertable field