Skip to content

Database

Factories, seeders and database rules

Model factories with a built-in Faker, seeders with db:seed, and the Unique and Exists validation rules.

Last updated:

Factories#

database/factories/user_factory.go
// vukem make:factory User
var User = factory.New(func(f *factory.Faker) models.User {
	return models.User{Name: f.Name(), Email: f.Email()}
})
Go
admins, err := factories.User.Count(3).State(func(u *models.User) { u.Role = "admin" }).Create(ctx, db)
draft := factories.User.MakeOne()                // not saved
same := factories.User.Seed(42).Count(5).Make()  // reproducible data

// Related models: AfterCreate runs inside Create's transaction.
withPosts := factories.User.AfterCreate(func(ctx context.Context, ex database.Executor, u *models.User) error {
	_, err := factories.Post.Count(2).State(func(p *models.Post) { p.UserID = u.ID }).Create(ctx, ex)
	return err
})
  • Factories are values: State, Count, Seed and AfterCreate return copies, so a base factory can be shared.
  • factory.Faker has no dependency: Name, FirstName, LastName, Username, Email (reserved example domains), Word, Words, Sentence, Paragraph, Slug, URL, UUID, Int, Float, Bool, Time, factory.Pick(f, ...) and Seq(). It uses math/rand/v2 and must never generate secrets.

Seeders#

database/seeders/posts_seeder.go
// vukem make:seeder Posts (registered in database/seeders/registry.go)
type PostsSeeder struct{}

func (PostsSeeder) Seed(ctx context.Context, db *database.DB) error {
	_, err := factories.Post.Count(10).Create(ctx, db)
	return err
}
Terminal
vukem db:seed   # runs every seeder in vukem.WithSeeders(seeders.All()...), in order
vukem db:wipe   # drops every table

Both commands need --force in production. Tests call app.Seed(ctx).

Seeders that need the app#

A Seeder only gets the database. A seeder that reads configuration, logs, or uses a service a provider opened (an S3 disk for seeded images) implements vukem.AppSeeder instead and is registered with vukem.WithAppSeeders:

database/seeders/users_seeder.go
func Users() vukem.AppSeeder {
	return vukem.NamedAppSeeder("Users", func(ctx context.Context, app *vukem.App) error {
		if app.Config.IsProduction() {
			return errors.New("demo users are not seeded in production")
		}

		db, err := app.DB()
		if err != nil {
			return err
		}

		password := app.Source.Get("SEED_PASSWORD")
		app.Log.InfoContext(ctx, "seeding demo users")

		return createDemoUsers(ctx, db, password)
	})
}
bootstrap/app.go
vukem.WithSeeders(seeders.All()...),
vukem.WithAppSeeders(seeders.Users()),

Both kinds run in one list, in option order. db:seed prints Seeded: <name> and errors read seed <name>: ..., where the name is the seeder's Name() string method when it has one, else its Go type (AppSeederFunc for every function); vukem.NamedAppSeeder("Users", fn) gives a function a name. db:seed boots the app before seeding, so services providers open in Boot are ready; call app.Boot(ctx) before app.Seed(ctx) when you seed from your own code. A provider's service can be handed to the seeder when bootstrap builds both (seeders.Media(mediaProvider)).

Database validation rules#

z schemas check shape and format without I/O. Rules that need the database run after parsing and return the same 422 validation error (flashed for forms):

Go
in, err := web.Parse(c, requests.RegisterSchema)
if err != nil {
	return err
}

if err := orm.Validate(c.Context(), db,
	orm.Unique[models.User]("email", in.Email),                          // soft-deleted rows count, like the index
	orm.Unique[models.User]("username", in.Username).Ignore(current.ID), // updates keep their own value
	orm.Exists[models.Team]("id", in.TeamID).Field("team_id").Message("Pick a valid team."),
); err != nil {
	return err
}

The built-in messages follow the request locale (validation.unique, validation.exists, with {field} from validation.attributes.<column>; see Localization); a Message override is used as is.

Empty values are skipped (required belongs to the schema). Columns are validated against the model.