Skip to content

Models & CRUD ​

What a model is, what it is not, and the four operations everything else is built out of.

Models and the schema they describe ​

core.IModel asks for one method. The interesting decisions are the ones around it: money as an integer count of the smallest unit, enums as strings with constants in front of them, UTC timestamps, and what gorm.DeletedAt does to a unique index. AutoMigrate appears once, so the example runs — see Schema & migrations for who owns the schema in production.

go
package main

import (
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/utils"
	"gorm.io/gorm"
)

// --- Example 1: models, column types, and who owns the schema ---------------
//
// core.IModel asks for exactly one method — TableName on the value, which is
// what makes repository.New[User] work rather than New[*User]. Everything else
// is ordinary GORM tagging.
//
// The struct *describes* a table a migration already created; it does not own
// one. devMigrate at the bottom of this file exists so `go run .` works against
// an empty sqlite database and for no other reason.

// A status is a string with constants in front of it, not an int. An int enum
// is unreadable in a psql session and silently reassigns itself the day someone
// inserts a value in the middle of the list.
type UserStatus string

const (
	UserActive  UserStatus = "active"
	UserTrial   UserStatus = "trial"
	UserDormant UserStatus = "dormant"
)

type OrderStatus string

const (
	OrderPending   OrderStatus = "pending"
	OrderPaid      OrderStatus = "paid"
	OrderCancelled OrderStatus = "cancelled"
)

type User struct {
	ID     string     `json:"id"     gorm:"column:id;primaryKey"`
	Email  string     `json:"email"  gorm:"column:email;uniqueIndex"`
	Name   string     `json:"name"   gorm:"column:name"`
	Status UserStatus `json:"status" gorm:"column:status;index"`

	// Money is an integer count of the smallest unit, never a float. 0.1 + 0.2
	// is not 0.3 in binary floating point, and a balance that drifts by a
	// satang every few thousand transactions is a bug nobody can reproduce.
	CreditsSatang int64 `json:"credits_satang" gorm:"column:credits_satang"`

	// GORM maintains these two, in UTC — core.NewDatabase sets NowFunc — so what
	// is written does not depend on the server's timezone. Convert to the user's
	// zone at the very top layer, never in the database.
	//
	// They are pointers because that is the house rule for every time column: a
	// value type cannot tell "not set yet" apart from year 1, and the zero time
	// is what a nullable column decodes into. utils.ToNonPointer reads one back
	// when the call site wants a value.
	CreatedAt *time.Time `json:"created_at" gorm:"column:created_at"`
	UpdatedAt *time.Time `json:"updated_at" gorm:"column:updated_at"`

	// gorm.DeletedAt is what turns Delete() into a soft delete and adds
	// "deleted_at IS NULL" to every query. Worth knowing before you add it: the
	// uniqueIndex on email above still sees the dead rows, so a deleted address
	// can never be registered again. On postgres that wants to be a partial
	// index instead — CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL.
	DeletedAt gorm.DeletedAt `json:"-" gorm:"column:deleted_at;index"`

	Profile *Profile `json:"profile,omitempty" gorm:"foreignKey:UserID"`
	Orders  []Order  `json:"orders,omitempty"  gorm:"foreignKey:UserID"`
}

func (User) TableName() string { return "users" }

// BeforeCreate is the one job hooks are good for: deriving a field from the row
// itself. A hook that sends mail, publishes an event or queries another table
// makes every write an invisible side effect that runs inside whatever
// transaction happens to be open — that belongs in a service method.
func (u *User) BeforeCreate(*gorm.DB) error {
	if u.ID == "" {
		u.ID = utils.NewUUID()
	}
	return nil
}

type Profile struct {
	ID     string `json:"id"      gorm:"column:id;primaryKey"`
	UserID string `json:"user_id" gorm:"column:user_id;uniqueIndex"`
	City   string `json:"city"    gorm:"column:city"`
}

func (Profile) TableName() string { return "profiles" }

func (p *Profile) BeforeCreate(*gorm.DB) error {
	if p.ID == "" {
		p.ID = utils.NewUUID()
	}
	return nil
}

type Order struct {
	ID     string      `json:"id"      gorm:"column:id;primaryKey"`
	UserID string      `json:"user_id" gorm:"column:user_id;index"`
	Status OrderStatus `json:"status"  gorm:"column:status;index"`
	// The pair (user_id, created_at) is the index this table actually wants:
	// one composite answers both "this user's orders" and "newest first",
	// which two single-column indexes do not.
	TotalSatang int64      `json:"total_satang" gorm:"column:total_satang"`
	CreatedAt   *time.Time `json:"created_at"   gorm:"column:created_at;index"`

	User  *User       `json:"user,omitempty"  gorm:"foreignKey:UserID"`
	Items []OrderItem `json:"items,omitempty" gorm:"foreignKey:OrderID"`
}

func (Order) TableName() string { return "orders" }

func (o *Order) BeforeCreate(*gorm.DB) error {
	if o.ID == "" {
		o.ID = utils.NewUUID()
	}
	return nil
}

type OrderItem struct {
	ID          string `json:"id"       gorm:"column:id;primaryKey"`
	OrderID     string `json:"order_id" gorm:"column:order_id;index"`
	SKU         string `json:"sku"      gorm:"column:sku"`
	Qty         int64  `json:"qty"      gorm:"column:qty"`
	PriceSatang int64  `json:"price_satang" gorm:"column:price_satang"`
}

func (OrderItem) TableName() string { return "order_items" }

func (i *OrderItem) BeforeCreate(*gorm.DB) error {
	if i.ID == "" {
		i.ID = utils.NewUUID()
	}
	return nil
}

// allModels is the list devMigrate and the seeds walk, in one place so a new
// table is one edit rather than three.
func allModels() []any { return []any{&User{}, &Profile{}, &Order{}, &OrderItem{}} }

// devMigrate builds the schema from the structs above.
//
// This is a development and test convenience only. AutoMigrate never drops a
// column, never changes a type, never renames, and knows nothing about partial
// indexes, check constraints or backfills — so the schema it produces drifts
// away from the one you think you have, silently, until a query fails in
// production for a reason that is nowhere in git history. Real schema belongs
// to a migration tool run as its own deploy step (v2/docs/migrations.md).
func devMigrate(db *gorm.DB) core.IError {
	if err := db.AutoMigrate(allModels()...); err != nil {
		return core.Wrap(err, "database example: dev migrate")
	}
	return nil
}

Create, read, update, delete ​

The repository takes its connection and its deadline from the context, so no query method needs a ctx. Also here: why a missing row is errmsgs.NotFound rather than a driver sentinel, why Updates with a struct silently ignores your false, and why branching a base query cannot leak conditions the way v1 did.

go
package main

import (
	"errors"

	core "gitlab.finema.co/finema/idin-core/v2"
	"gitlab.finema.co/finema/idin-core/v2/errmsgs"
	"gitlab.finema.co/finema/idin-core/v2/repository"
	"gorm.io/gorm"
)

// --- Example 2: create, read, update, delete --------------------------------
//
// repository.New[M](ctx) takes both the connection and the deadline from the
// context, so no query method needs a ctx argument — and a client that hangs up
// cancels the query it started. Nothing here returns a raw error: a missing row
// is errmsgs.NotFound, everything else is DATABASE_ERROR, and both already
// carry the status the HTTP layer will use.

func crudTour(ctx core.IContext) core.IError {
	users := repository.New[User](ctx)

	// Create takes a pointer because the driver writes back into it: the id from
	// BeforeCreate and the timestamps GORM fills are only visible that way.
	u := User{Email: "[email protected]", Name: "Ann", Status: UserTrial, CreditsSatang: 25_000}
	if err := users.Create(&u); err != nil {
		return err
	}
	ctx.Log().Info("created", "id", u.ID, "created_at", u.CreatedAt)

	// A missing row is a named outcome, not a driver sentinel. errors.Is works
	// through the wrapping, so this holds however the error travelled.
	if _, err := users.Where("email = ?", "[email protected]").FindOne(); !errors.Is(err, errmsgs.NotFound) {
		return core.Newf(500, "EXAMPLE_FAILED", "expected NOT_FOUND, got %v", err)
	}

	// Count, Exists and FindAll never report emptiness as an error — zero rows
	// is an answer. Only FindOne, Take and Last can miss.
	n, err := users.Where("status = ?", UserTrial).Count()
	if err != nil {
		return err
	}
	ctx.Log().Info("trial users", "count", n)

	if err := crudCopyOnWrite(ctx); err != nil {
		return err
	}
	return crudUpdateAndDelete(ctx, u.ID)
}

// crudCopyOnWrite is the property that removes a whole class of v1 bugs: every
// chainable method clones, so a base query can be branched without one branch's
// conditions leaking into the next.
func crudCopyOnWrite(ctx core.IContext) core.IError {
	base := repository.New[User](ctx).Where("credits_satang > ?", 0)

	trial, err := base.Where("status = ?", UserTrial).Count()
	if err != nil {
		return err
	}
	active, err := base.Where("status = ?", UserActive).Count()
	if err != nil {
		return err
	}
	// Neither count saw the other's condition, and base is still just
	// "credits > 0". A Repo value is a query, not a connection — it is safe to
	// keep on a service struct and share between goroutines.
	ctx.Log().Info("branched off one base query", "trial", trial, "active", active)
	return nil
}

func crudUpdateAndDelete(ctx core.IContext, id string) core.IError {
	users := repository.New[User](ctx)

	// One column.
	if err := users.Where("id = ?", id).Update("name", "Ann B."); err != nil {
		return err
	}

	// Several columns. Use a map whenever a zero value is a value you mean:
	// Updates with a *struct* skips zero fields, so Status: "" and
	// CreditsSatang: 0 are indistinguishable from "not set" and are left alone.
	if err := users.Where("id = ?", id).Updates(map[string]any{
		"status":         UserActive,
		"credits_satang": 0,
	}); err != nil {
		return err
	}

	// Arithmetic belongs in the database. Read-modify-write in Go loses updates
	// the moment two requests do it at once.
	if err := users.Where("id = ?", id).
		Update("credits_satang", gorm.Expr("credits_satang + ?", 500)); err != nil {
		return err
	}

	// When the row count *is* the answer — a compare-and-swap, "did that
	// actually change anything" — take it from DB(). Putting the precondition
	// in the WHERE makes check-then-act atomic with no transaction and no lock.
	res := users.Where("id = ? AND status = ?", id, UserActive).
		DB().Update("status", UserDormant)
	if res.Error != nil {
		return ctx.NewError(res.Error, errmsgs.DBError)
	}
	if res.RowsAffected == 0 {
		return ctx.NewError(nil, errmsgs.NotFound)
	}

	// Soft delete: the row stays, and every later query filters it out for you.
	if err := users.Where("id = ?", id).Delete(); err != nil {
		return err
	}
	still, err := users.Unscoped().Where("id = ?", id).Count()
	if err != nil {
		return err
	}
	ctx.Log().Info("soft deleted", "rows_still_on_disk", still)

	// HardDelete is the only way to free the unique email again.
	return users.Where("id = ?", id).HardDelete()
}

Maintained by Passakon Puttasuwan & Dev Core Team.