go-kanna/kanna

Go code generators built on the standard library — DI, struct mapping, test fixtures, ORM. Each works on its own and emits plain Go with no runtime reflection.

3

stars

221

commits

Go

primary language

Sep 10, 2026

updated

codegen
code-generation
dependency-injection
go
go-generate
golang
migrations
orm
struct-mapping
test-fixtures
type-safe

README

kanna

CI codecov Go Reference License: MIT Release

Go code generators built on the standard library — DI, struct mapping, test fixtures, ORM, i18n. Each works on its own and emits plain Go with no runtime reflection.

Your structs are the source of truth. Point a generator at a package and it writes the code you would otherwise write by hand, so the output stays readable, debuggable, and free of anything to learn at runtime.

kanna-di

Wires a container struct from the providers it can find, and writes a plain constructor.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-di

Use

A provider is a top-level function whose first result is a named type, a pointer to one, or an interface, optionally followed by an error. Nothing needs to be registered — kanna-di finds them by scanning the packages you point it at.

A container is a struct whose fields carry a di tag.

package app

//go:generate go tool kanna-di ./...

type DB struct{}

func NewDB() *DB { return &DB{} }

type User struct{ db *DB }

func NewUser(db *DB) User { return User{db: db} }

type Container struct {
	User User `di:""`
}

go generate ./...

writes di_gen.go next to it:

// Code generated by kanna. DO NOT EDIT.

package app

// NewContainer initializes dependencies and constructs Container.
func NewContainer() *Container {
	db := NewDB()
	user := NewUser(db)

	return &Container{
		User: user,
	}
}

When any provider in the chain returns an error, the constructor returns one too and propagates it.

Tags

A field may be named — the resolved value is stored in it — or blank (_), which declares something about the container without keeping a value.

TagOn a named fieldOn a blank field
di:""resolve from whichever provider returns the field's type
di:"with=<ref>"resolve from the named providerpick that provider for the type everywhere in this container
di:"arg"take it as a constructor parameter, named after its type, and store ittake it as a parameter only
di:"arg=<name>"same, with the parameter name spelled outsame, with the parameter name spelled out
di:"returns"store it and declare its type as the constructor's return typedeclare the return type only
di:"embed"take a struct as a parameter and offer its exported fields as resolution sources

<ref> may be a bare function name (NewWriter), a package-qualified one (config.NewWriter), or fully qualified ( github.com/me/config.NewWriter).

Directives

A //kanna:container comment above the struct adjusts what gets generated. It is optional — one di tag is enough to make a struct a container.

DirectiveEffect
//kanna:container name=<ident>name the constructor (default: New + struct name)
//kanna:container returns=<type>declare the return type (default: pointer to the struct)
//kanna:container mustalso emit MustNew*, which panics instead of returning the error

Write the tag against the comment marker. Go only treats //kanna:… as a directive when the two are adjacent; with a space, // kanna:container stays part of the doc comment and shows up in go doc and on pkg.go.dev. kanna does not honor that form either, and points out the spelling it expected.

returns= takes the container's own type to construct it by value, or an interface the container satisfies to hide the concrete type.

Flags

FlagMeaning
--mustemit MustNew* for every container
--tags <list>comma-separated build tags
-checkverify generated files are up to date instead of writing them
-v, --verboseverbose output

Example

examples/di wires a small application covering every directive and every tag except di:"arg=<name>", which needs a name collision before it is worth showing. CI regenerates it and fails if the output would change, so what you read there is what the generator currently produces.

kanna-fixture

Writes one constructor per struct in a package, with every field already filled, so a test states only what it cares about.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-fixture

Use

Point it at the package holding your models and at the directory the fixtures should live in. Every exported struct there gets a function — there is nothing to opt into, which is what keeps fixtures from falling behind the model.

package model

//go:generate go tool kanna-fixture -source ./model -destination ./fixture

type User struct {
	ID    int64
	Name  string
	Email string
	Age   int `fake:"{number:18,65}"`
}
go generate ./...

writes fixture/fixture_gen.go:

// Code generated by kanna. DO NOT EDIT.

package fixture

import (
	"github.com/brianvoe/gofakeit/v7"

	"example.com/app/model"
)

func User(setters ...func(m *model.User)) model.User {
	m := model.User{
		ID:    gofakeit.Int64(),
		Name:  gofakeit.Name(),
		Email: gofakeit.Email(),
		Age:   gofakeit.Number(18, 65),
	}
	for _, s := range setters {
		s(&m)
	}
	return m
}
u := fixture.User(func(m *model.User) { m.Email = "known@example.com" })

The generated code calls gofakeit, plus whatever else the values it builds need — github.com/google/uuid for a uuid.UUID field, for instance. When the destination module does not require one of them yet, the generator says so.

Generation is deterministic; the values are not. Seed the faker when a test needs the same data twice:

func TestMain(m *testing.M) {
	if err := gofakeit.Seed(1); err != nil {
		panic(err)
	}

	os.Exit(m.Run())
}

Inference

Each field takes the first rule that matches.

RuleApplies whenResult
fake tagthe field carries onesee below
field namethe name is known and the type agreesEmail stringEmail()
field typethe type has a fakerboolBool()
a struct generated in this runthe field is a value of that typeAuthor UserUser()
otherwisezero value

Names matched on a string field: Email, Name, FirstName, LastName, Phone, URL, UUID, Address, City, Country. Any field whose name ends in At and whose type is time.Time gets a date.

Types matched: every string, bool, int, uint, and float kind, plus time.Time and github.com/google/uuid's UUID.

Left at the zero value: pointers, slices, maps, interfaces, channels, funcs, named basic types without a tag (the valid values are not knowable — type Status string could be anything), and any reference that would recurse, including a struct that points back at its own type.

Unexported fields are skipped, since the generated package cannot set them.

Tags

The fake tag follows gofakeit's own template syntax, so there is nothing new to learn.

TagEffect
fake:"{email}"use that generator — also {firstname}, {name}, {phone}, {url}, and others
fake:"{number:18,65}"a parameterized generator — also {intrange:…}, {uintrange:…}, {price:…}
fake:"???-####"any other template, resolved at run time through gofakeit.Generate
fake:"skip" or "-"leave the field at its zero value

A tag also opts a named basic type back in: a Status field tagged fake:"{word}" emits model.Status(gofakeit.Word()).

A template that cannot produce the field's type is refused rather than forced, and the field stays zero. That covers a range too wide for the field ({number:1,300} on an int8) and a mismatched kind ({email} on an int), so the generated file always compiles.

Directives

DirectiveEffect
//kanna:ignoredo not generate a fixture for this struct

Write it against the comment marker. // kanna:ignore is not a directive to Go, so kanna does not treat it as one either, and says so.

Graphs

When the source structs carry kanna-orm relations, fixtures grow a second form: every //kanna:table struct whose belongs_to foreign keys cannot be NULL gets a graph — the record bundled with everything it needs to exist, keys consistent, records in insertion order.

g := fixture.NewEmployeeGraph(func(g *fixture.EmployeeGraph) {
	g.Department.Name = "Engineering"
})
// g.Employee.DepartmentID == g.Department.ID, g.Department.CompanyID == g.Company.ID

for _, rec := range g.Records() { // foreign-key insertion order
	// insert rec
}

Keys are set before any insert — integer keys count up a process-wide counter, string keys become UUIDs — which is what lets Records() go into a real database as-is: kanna-orm's Create takes a caller-set key verbatim. A nullable foreign key (*int64) marks the parent optional, so the graph neither builds nor wires it. Share a parent by plain assignment and call Wire() to make the keys follow:

colleague := fixture.NewEmployeeGraph()
colleague.Department = g.Department
colleague.Wire()

Records() sees one graph at a time: when two graphs share a parent, insert the shared record once. A package without orm tags is untouched: graphs appear only where relations do.

Flags

FlagMeaning
-source <pkg>package to scan (relative path or import path)
-destination <dir>directory to write fixture_gen.go into
-package <name>package name for the generated file (default: what the destination declares)
-exclude <names>comma-separated type names to skip
-checkverify the output is up to date instead of writing it

The destination has to be a different package from the source; a fixture that imported its own package would not compile.

Example

examples/fixture generates fixtures for a model covering each inference rule, then builds values from them. CI regenerates it and fails if the output would change.

kanna-mapper

Writes the mapping functions between your domain types and the wire types you do not control, calling the converters you registered for the fields Go cannot convert on its own.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-mapper
go get github.com/go-kanna/kanna/mapper

Two lines, because this generator has a runtime half. The package declaring your converters imports github.com/go-kanna/kanna/mapper; the generated code does not.

Use

Register the conversions Go cannot do by itself, once, anywhere:

package converters

import "github.com/go-kanna/kanna/mapper"

func init() {
	mapper.Register(UUIDToString) // uuid.UUID → string
	mapper.RegisterE(uuid.Parse)  // string → uuid.UUID, and this one can fail
}

Then name the pairs to map:

//go:generate go tool kanna-mapper -types=model.Employee:*employeev1.Employee -converter-pkg=../lib/converters
package mapper

import (
	_ "example.com/app/gen/employeev1"
	_ "example.com/app/model"
)

The blank imports are what make model and employeev1 resolvable as selectors. If the package already imports those types for real use, the directive stands on its own; full import paths work too.

go generate ./... writes the functions:

// EmployeeToEmployeev1 maps model.Employee to *employeev1.Employee.
func EmployeeToEmployeev1(src model.Employee) *employeev1.Employee {
	return &employeev1.Employee{
		Id:      converters.UUIDToString(src.ID),
		Name:    src.Name,
		Address: AddressToEmployeev1(src.Address),
	}
}

// EmployeeFromEmployeev1 maps *employeev1.Employee to model.Employee.
func EmployeeFromEmployeev1(src *employeev1.Employee) (model.Employee, error) {
	if src == nil {
		return model.Employee{}, nil
	}
	v1, err0 := uuid.Parse(src.GetId())
	if err0 != nil {
		return model.Employee{}, fmt.Errorf("map model.Employee.ID: %w", err0)
	}
	// ...
}

The direction that can fail returns an error naming the field that produced it. The direction that cannot, does not. Nil-safe getters are used when the wire type has them.

mapper.Register is never executed by the generator: the calls are read statically, and the functions they name are called directly. The registry also works at run time through mapper.Convert if you want it.

How fields are matched

Each destination field takes the first rule that matches.

RuleExample
the destination field's own taga destination tagged map:"EmployeeName" reads that field
a source field tagged with the destination's namea source tagged map:"Name" fills Name
the same nameNameName
the same name, any caseIDId
a promoted fieldan embedded struct's field, by exact name

A destination field with no source is an error, not a silent zero value. Exclude it with map:"-" on the source, or -ignore TYPE.FIELD when the type is not yours to tag.

How values are converted

CaseResult
identical typesassigned as-is
a registered converter existsthat function is called
the pair is declared in -typesthe generated function for it is called
source is a pointerdereferenced; nil leaves the destination zero
destination is a pointerthe value's address is taken
both are slicesconverted element-wise; nil maps to nil
a lossless Go conversion existsdst(v)

"Lossless" is meant strictly: int32int64 converts, int64int32 does not. Neither does intint32 (int is 64 bits on some platforms), intuint (sign), int64float64 (precision), or anything → string. Everything else needs a converter, and the error message shows the mapper.Register line that would satisfy it.

With kanna-orm models

When a pair type is a //kanna:table struct, the mapper reads the same orm tags the ORM generator does:

  • A relation field (has_many, belongs_to, ...) with no counterpart in the wire type is skipped silently. It is a query artifact, not row data, so it no longer needs a map:"-".
  • With -direction to, a persisted column the To function never reads gets a warning — that is the one mode where a schema-backed field can drop out of the API silently. Map it, tag it map:"-", or pass -exclude to say the omission is deliberate.
  • A malformed orm tag never fails the mapper; enforcing tags is kanna-orm's job. It only costs this awareness, with a warning.

Packages without orm tags are untouched.

Flags

FlagMeaning
-types <SRC:DST>pairs to map, comma-separated; repeatable. * marks a pointer
-converters <pkg>package holding the mapper.Register calls; repeatable
-exclude <TYPE.FIELD>destination fields to exclude; repeatable
-output <path>output directory, or a file path ending in .go
-direction <dir>both (default), to, or from
-package <name>output package name (default: $GOPACKAGE)
-checkverify the output is up to date instead of writing it

Example

examples/mapper maps a domain aggregate onto protobuf-shaped wire types and back, covering each way a field can be handled: renamed with map:"Name", excluded from the domain side with map:"-", excluded from the wire side with -exclude where there is no tag to write, converted through a registered function, and converted through one that can fail. CI regenerates it and fails if the output would change.

kanna-orm

Generates type-safe query code from annotated model structs: a factory returning orm.Query[T] per table, row scanning, relations with eager loading, and automatic timestamps. The generated code is plain Go on top of the orm/ runtime, which brings the query builder, MySQL/PostgreSQL dialects, and transactions.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-orm

Annotate

//kanna:table opts a struct in; everything else is inferred from the fields and overridden with orm tags where the inference is not what the schema says.

package model

import "time"

//kanna:table
type User struct {
	ID        int       // primary key by name
	Name      string    // column "name"
	Email     string    `orm:"email_address"` // explicit column name
	CreatedAt time.Time // set automatically on create
	Posts     []Post    `orm:"has_many,foreign_key:user_id"`
}

//kanna:table
type Post struct {
	ID     int
	UserID int
	Title  string
	User   *User `orm:"belongs_to,foreign_key:user_id"`
}

Generate

//go:generate go tool kanna-orm -source ./model -destination ./query

The destination must be a package of its own — generating into the model package would leave it uncompilable whenever the output goes stale.

Use

db := orm.New(sqlDB, orm.MySQL) // or orm.PostgreSQL

users, err := query.Users(db).Where("name LIKE ?", "A%").OrderBy("id").All(ctx)
posts, err := query.Posts(db).Preload("User").All(ctx)

err = db.Transaction(ctx, func(tx orm.Querier) error {
	return query.Users(tx).Create(ctx, &model.User{Name: "Alice"})
})

Tags

The first element of an orm tag is either a relation kind or a column name; everything after it is an option.

TagMeaning
(no tag)column inferred from the field name (CreatedAtcreated_at)
orm:"col_name"explicit column name
orm:"-"not a column
orm:",primary_key"primary key (default: the field named ID)
orm:",created_at" / orm:",updated_at"timestamp managed on create/update (default: fields named CreatedAt/UpdatedAt)
orm:"has_many,foreign_key:user_id"one-to-many; the field is a slice
orm:"has_one,foreign_key:user_id"one-to-one; the target holds the key
orm:"belongs_to,foreign_key:user_id"the owning side; this struct holds the key
orm:"many_to_many,join_table:user_tags,foreign_key:user_id,references:tag_id"via a join table

Table names are pluralized snake_case (UserProfileuser_profiles); //kanna:table name=people overrides one. Name inference is mechanical — there is deliberately no acronym dictionary, so a mixed-case name like OAuthToken takes its column from the tag. Anything malformed — an unknown option, a relation whose target generates no queries, a missing foreign key column — is a positioned error, not a silent skip.

Flags

FlagDescription
-source <pkg>source package to scan
-destination <dir>output directory for orm_gen.go
-package <name>generated package name (defaults to what the destination declares)
-checkverify the output is up to date instead of writing it

Example

examples/orm drives the generated queries end to end against real MySQL and PostgreSQL: scopes, joins, the three preload kinds, transactions, batch inserts, and upserts. CI regenerates it, runs it against both databases, and fails if the output would change.

kanna-i18n

Generates typed message constructors from a directory of locale files — and compiles the translations themselves into the output, so nothing is parsed or read at run time.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-i18n
go get github.com/go-kanna/kanna/i18n

Two lines, because this generator has a runtime half: both the generated code and the code calling Localize import github.com/go-kanna/kanna/i18n, which carries the CLDR plural rules, locale-aware number formatting, and language fallback that depend on run-time values.

Use

One file per language, named by its BCP 47 tag:

# locales/en.yaml
greeting: "Hello!"
hello: "Hello, {name}!"
items_count:
  plural:
    one: "You have {count} item."
    other: "You have {count} items."
total_price: "Total: {price:number}"
user:
  not_found: "User not found."
//go:generate go tool kanna-i18n

With the defaults, that one line reads locales/ and writes messages/i18n_gen.go: a constructor per message, a Localizer accessor, and the translations of every language as an embedded bundle.

Discovery is flat: one file defines one language, and subdirectories are never scanned. Nothing locale-shaped is skipped in silence — a subdirectory or unreadable file named like a locale (locales/en/, en.json) fails the run, and other non-locale files are skipped with a warning.

// Hello returns the "hello" message.
func Hello(name string) i18n.Message {
	return i18n.Message{Key: "hello", Args: []i18n.Arg{
		{Name: "name", Value: name},
	}}
}

// Localizer renders this package's messages in the compiled locale best
// matching tag.
func Localizer(tag language.Tag) i18n.Localizer {
	return bundle.Localizer(tag)
}

// bundle holds every locale this package was generated from.
var bundle = i18n.NewBundle("en",
	i18n.Catalog{Lang: "en", Entries: map[string]i18n.Entry{
		"hello": {Single: i18n.Template{{Text: "Hello, "}, {Param: "name"}, {Text: "!"}}},
		// ...
	}},
)

Calling it takes no setup at all:

en := messages.Localizer(language.English)
fmt.Println(en.Localize(messages.Hello("World")))

The default language (-default, en unless said otherwise) defines the constructor signatures; every other locale is validated against it at generation time. At run time, a message missing from the requested language falls back through the language's parents (en-GB falls back to en) and finally to the default language, and requesting a language that was never compiled in gets the default outright.

Locale files

The locale directory is flat — one file per language, no recursion — and the filename stem is the language: en.yaml, pt-BR.yaml, ja.toml. YAML (.yaml, .yml) and TOML (.toml) both work. Files whose stem is not a language tag are skipped with a warning, so a stray config.yaml does not fail the run.

Nested mappings become dot-joined keys, and keys become constructor names: user.not_found generates UserNotFound(). Keys and parameter names match [a-z][a-z0-9_]* per segment.

A message declares its plural forms under an explicit plural mapping, keyed by CLDR category (zero, one, two, few, many, other) and always defining other. The marker is explicit so intent is never guessed from shape: keys that merely happen to be named one or other are ordinary nesting, and the only name a locale file cannot use freely is a mapping-valued plural. The generated constructor takes count int first, and the count picks the variant under the rendering language's own plural rules — Japanese has no one form, and that is fine.

A plural group that skips forms its language does use gets a warning, because those counts silently render with other: Russian providing only other is missing one, few, and many, while Japanese providing only other is complete.

Placeholders

FormMeaning
{name}a string parameter
{name:int}a plain integer, rendered as-is — counts, IDs
{name:number}a float64, rendered with the locale's conventions: 1,234.56 in en, 1.234,56 in de
{{ and }}literal braces

A bare placeholder inherits an explicit kind annotated elsewhere in the same message; conflicting annotations are an error.

Validation

Errors fail the generation: a key a translation has but the default language does not, a plural group where the default has a plain message (or the reverse), a parameter the default language never mentions, and conflicting kind annotations across plural variants.

Missing translations are warnings, not errors, because the runtime falls back to the default language. The generated code never breaks when a locale lags behind; it renders the default until the translation lands.

Flags

FlagMeaning
-locales <dir>directory containing locale files (default: locales)
-default <lang>default language defining the generated signatures (default: en)
-destination <dir>output directory for the generated file (default: messages)
-package <name>package name of the generated file (default: base of the output directory)
-checkverify the output is up to date instead of writing it

Example

examples/i18n renders the same messages in English and Japanese — plurals, locale-formatted numbers, and a fallback for a missing translation — with zero run-time setup. CI regenerates it and fails if the output would change.

Development

make test        # unit tests
make lint        # golangci-lint
make examples    # regenerate every example, then build and run it

The repository is a Go workspace: go.work points the examples' tool directive at this checkout, so go generate inside an example runs the generator you have locally rather than a published version.

License

MIT

Contributors

mickamy

216 commits

go-kanna/kanna

Go code generators built on the standard library — DI, struct mapping, test fixtures, ORM. Each works on its own and emits plain Go with no runtime reflection.

3

stars

221

commits

Go

primary language

Sep 10, 2026

updated

codegen
code-generation
dependency-injection
go
go-generate
golang
migrations
orm
struct-mapping
test-fixtures
type-safe

README

kanna

CI codecov Go Reference License: MIT Release

Go code generators built on the standard library — DI, struct mapping, test fixtures, ORM, i18n. Each works on its own and emits plain Go with no runtime reflection.

Your structs are the source of truth. Point a generator at a package and it writes the code you would otherwise write by hand, so the output stays readable, debuggable, and free of anything to learn at runtime.

kanna-di

Wires a container struct from the providers it can find, and writes a plain constructor.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-di

Use

A provider is a top-level function whose first result is a named type, a pointer to one, or an interface, optionally followed by an error. Nothing needs to be registered — kanna-di finds them by scanning the packages you point it at.

A container is a struct whose fields carry a di tag.

package app

//go:generate go tool kanna-di ./...

type DB struct{}

func NewDB() *DB { return &DB{} }

type User struct{ db *DB }

func NewUser(db *DB) User { return User{db: db} }

type Container struct {
	User User `di:""`
}

go generate ./...

writes di_gen.go next to it:

// Code generated by kanna. DO NOT EDIT.

package app

// NewContainer initializes dependencies and constructs Container.
func NewContainer() *Container {
	db := NewDB()
	user := NewUser(db)

	return &Container{
		User: user,
	}
}

When any provider in the chain returns an error, the constructor returns one too and propagates it.

Tags

A field may be named — the resolved value is stored in it — or blank (_), which declares something about the container without keeping a value.

TagOn a named fieldOn a blank field
di:""resolve from whichever provider returns the field's type
di:"with=<ref>"resolve from the named providerpick that provider for the type everywhere in this container
di:"arg"take it as a constructor parameter, named after its type, and store ittake it as a parameter only
di:"arg=<name>"same, with the parameter name spelled outsame, with the parameter name spelled out
di:"returns"store it and declare its type as the constructor's return typedeclare the return type only
di:"embed"take a struct as a parameter and offer its exported fields as resolution sources

<ref> may be a bare function name (NewWriter), a package-qualified one (config.NewWriter), or fully qualified ( github.com/me/config.NewWriter).

Directives

A //kanna:container comment above the struct adjusts what gets generated. It is optional — one di tag is enough to make a struct a container.

DirectiveEffect
//kanna:container name=<ident>name the constructor (default: New + struct name)
//kanna:container returns=<type>declare the return type (default: pointer to the struct)
//kanna:container mustalso emit MustNew*, which panics instead of returning the error

Write the tag against the comment marker. Go only treats //kanna:… as a directive when the two are adjacent; with a space, // kanna:container stays part of the doc comment and shows up in go doc and on pkg.go.dev. kanna does not honor that form either, and points out the spelling it expected.

returns= takes the container's own type to construct it by value, or an interface the container satisfies to hide the concrete type.

Flags

FlagMeaning
--mustemit MustNew* for every container
--tags <list>comma-separated build tags
-checkverify generated files are up to date instead of writing them
-v, --verboseverbose output

Example

examples/di wires a small application covering every directive and every tag except di:"arg=<name>", which needs a name collision before it is worth showing. CI regenerates it and fails if the output would change, so what you read there is what the generator currently produces.

kanna-fixture

Writes one constructor per struct in a package, with every field already filled, so a test states only what it cares about.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-fixture

Use

Point it at the package holding your models and at the directory the fixtures should live in. Every exported struct there gets a function — there is nothing to opt into, which is what keeps fixtures from falling behind the model.

package model

//go:generate go tool kanna-fixture -source ./model -destination ./fixture

type User struct {
	ID    int64
	Name  string
	Email string
	Age   int `fake:"{number:18,65}"`
}
go generate ./...

writes fixture/fixture_gen.go:

// Code generated by kanna. DO NOT EDIT.

package fixture

import (
	"github.com/brianvoe/gofakeit/v7"

	"example.com/app/model"
)

func User(setters ...func(m *model.User)) model.User {
	m := model.User{
		ID:    gofakeit.Int64(),
		Name:  gofakeit.Name(),
		Email: gofakeit.Email(),
		Age:   gofakeit.Number(18, 65),
	}
	for _, s := range setters {
		s(&m)
	}
	return m
}
u := fixture.User(func(m *model.User) { m.Email = "known@example.com" })

The generated code calls gofakeit, plus whatever else the values it builds need — github.com/google/uuid for a uuid.UUID field, for instance. When the destination module does not require one of them yet, the generator says so.

Generation is deterministic; the values are not. Seed the faker when a test needs the same data twice:

func TestMain(m *testing.M) {
	if err := gofakeit.Seed(1); err != nil {
		panic(err)
	}

	os.Exit(m.Run())
}

Inference

Each field takes the first rule that matches.

RuleApplies whenResult
fake tagthe field carries onesee below
field namethe name is known and the type agreesEmail stringEmail()
field typethe type has a fakerboolBool()
a struct generated in this runthe field is a value of that typeAuthor UserUser()
otherwisezero value

Names matched on a string field: Email, Name, FirstName, LastName, Phone, URL, UUID, Address, City, Country. Any field whose name ends in At and whose type is time.Time gets a date.

Types matched: every string, bool, int, uint, and float kind, plus time.Time and github.com/google/uuid's UUID.

Left at the zero value: pointers, slices, maps, interfaces, channels, funcs, named basic types without a tag (the valid values are not knowable — type Status string could be anything), and any reference that would recurse, including a struct that points back at its own type.

Unexported fields are skipped, since the generated package cannot set them.

Tags

The fake tag follows gofakeit's own template syntax, so there is nothing new to learn.

TagEffect
fake:"{email}"use that generator — also {firstname}, {name}, {phone}, {url}, and others
fake:"{number:18,65}"a parameterized generator — also {intrange:…}, {uintrange:…}, {price:…}
fake:"???-####"any other template, resolved at run time through gofakeit.Generate
fake:"skip" or "-"leave the field at its zero value

A tag also opts a named basic type back in: a Status field tagged fake:"{word}" emits model.Status(gofakeit.Word()).

A template that cannot produce the field's type is refused rather than forced, and the field stays zero. That covers a range too wide for the field ({number:1,300} on an int8) and a mismatched kind ({email} on an int), so the generated file always compiles.

Directives

DirectiveEffect
//kanna:ignoredo not generate a fixture for this struct

Write it against the comment marker. // kanna:ignore is not a directive to Go, so kanna does not treat it as one either, and says so.

Graphs

When the source structs carry kanna-orm relations, fixtures grow a second form: every //kanna:table struct whose belongs_to foreign keys cannot be NULL gets a graph — the record bundled with everything it needs to exist, keys consistent, records in insertion order.

g := fixture.NewEmployeeGraph(func(g *fixture.EmployeeGraph) {
	g.Department.Name = "Engineering"
})
// g.Employee.DepartmentID == g.Department.ID, g.Department.CompanyID == g.Company.ID

for _, rec := range g.Records() { // foreign-key insertion order
	// insert rec
}

Keys are set before any insert — integer keys count up a process-wide counter, string keys become UUIDs — which is what lets Records() go into a real database as-is: kanna-orm's Create takes a caller-set key verbatim. A nullable foreign key (*int64) marks the parent optional, so the graph neither builds nor wires it. Share a parent by plain assignment and call Wire() to make the keys follow:

colleague := fixture.NewEmployeeGraph()
colleague.Department = g.Department
colleague.Wire()

Records() sees one graph at a time: when two graphs share a parent, insert the shared record once. A package without orm tags is untouched: graphs appear only where relations do.

Flags

FlagMeaning
-source <pkg>package to scan (relative path or import path)
-destination <dir>directory to write fixture_gen.go into
-package <name>package name for the generated file (default: what the destination declares)
-exclude <names>comma-separated type names to skip
-checkverify the output is up to date instead of writing it

The destination has to be a different package from the source; a fixture that imported its own package would not compile.

Example

examples/fixture generates fixtures for a model covering each inference rule, then builds values from them. CI regenerates it and fails if the output would change.

kanna-mapper

Writes the mapping functions between your domain types and the wire types you do not control, calling the converters you registered for the fields Go cannot convert on its own.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-mapper
go get github.com/go-kanna/kanna/mapper

Two lines, because this generator has a runtime half. The package declaring your converters imports github.com/go-kanna/kanna/mapper; the generated code does not.

Use

Register the conversions Go cannot do by itself, once, anywhere:

package converters

import "github.com/go-kanna/kanna/mapper"

func init() {
	mapper.Register(UUIDToString) // uuid.UUID → string
	mapper.RegisterE(uuid.Parse)  // string → uuid.UUID, and this one can fail
}

Then name the pairs to map:

//go:generate go tool kanna-mapper -types=model.Employee:*employeev1.Employee -converter-pkg=../lib/converters
package mapper

import (
	_ "example.com/app/gen/employeev1"
	_ "example.com/app/model"
)

The blank imports are what make model and employeev1 resolvable as selectors. If the package already imports those types for real use, the directive stands on its own; full import paths work too.

go generate ./... writes the functions:

// EmployeeToEmployeev1 maps model.Employee to *employeev1.Employee.
func EmployeeToEmployeev1(src model.Employee) *employeev1.Employee {
	return &employeev1.Employee{
		Id:      converters.UUIDToString(src.ID),
		Name:    src.Name,
		Address: AddressToEmployeev1(src.Address),
	}
}

// EmployeeFromEmployeev1 maps *employeev1.Employee to model.Employee.
func EmployeeFromEmployeev1(src *employeev1.Employee) (model.Employee, error) {
	if src == nil {
		return model.Employee{}, nil
	}
	v1, err0 := uuid.Parse(src.GetId())
	if err0 != nil {
		return model.Employee{}, fmt.Errorf("map model.Employee.ID: %w", err0)
	}
	// ...
}

The direction that can fail returns an error naming the field that produced it. The direction that cannot, does not. Nil-safe getters are used when the wire type has them.

mapper.Register is never executed by the generator: the calls are read statically, and the functions they name are called directly. The registry also works at run time through mapper.Convert if you want it.

How fields are matched

Each destination field takes the first rule that matches.

RuleExample
the destination field's own taga destination tagged map:"EmployeeName" reads that field
a source field tagged with the destination's namea source tagged map:"Name" fills Name
the same nameNameName
the same name, any caseIDId
a promoted fieldan embedded struct's field, by exact name

A destination field with no source is an error, not a silent zero value. Exclude it with map:"-" on the source, or -ignore TYPE.FIELD when the type is not yours to tag.

How values are converted

CaseResult
identical typesassigned as-is
a registered converter existsthat function is called
the pair is declared in -typesthe generated function for it is called
source is a pointerdereferenced; nil leaves the destination zero
destination is a pointerthe value's address is taken
both are slicesconverted element-wise; nil maps to nil
a lossless Go conversion existsdst(v)

"Lossless" is meant strictly: int32int64 converts, int64int32 does not. Neither does intint32 (int is 64 bits on some platforms), intuint (sign), int64float64 (precision), or anything → string. Everything else needs a converter, and the error message shows the mapper.Register line that would satisfy it.

With kanna-orm models

When a pair type is a //kanna:table struct, the mapper reads the same orm tags the ORM generator does:

  • A relation field (has_many, belongs_to, ...) with no counterpart in the wire type is skipped silently. It is a query artifact, not row data, so it no longer needs a map:"-".
  • With -direction to, a persisted column the To function never reads gets a warning — that is the one mode where a schema-backed field can drop out of the API silently. Map it, tag it map:"-", or pass -exclude to say the omission is deliberate.
  • A malformed orm tag never fails the mapper; enforcing tags is kanna-orm's job. It only costs this awareness, with a warning.

Packages without orm tags are untouched.

Flags

FlagMeaning
-types <SRC:DST>pairs to map, comma-separated; repeatable. * marks a pointer
-converters <pkg>package holding the mapper.Register calls; repeatable
-exclude <TYPE.FIELD>destination fields to exclude; repeatable
-output <path>output directory, or a file path ending in .go
-direction <dir>both (default), to, or from
-package <name>output package name (default: $GOPACKAGE)
-checkverify the output is up to date instead of writing it

Example

examples/mapper maps a domain aggregate onto protobuf-shaped wire types and back, covering each way a field can be handled: renamed with map:"Name", excluded from the domain side with map:"-", excluded from the wire side with -exclude where there is no tag to write, converted through a registered function, and converted through one that can fail. CI regenerates it and fails if the output would change.

kanna-orm

Generates type-safe query code from annotated model structs: a factory returning orm.Query[T] per table, row scanning, relations with eager loading, and automatic timestamps. The generated code is plain Go on top of the orm/ runtime, which brings the query builder, MySQL/PostgreSQL dialects, and transactions.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-orm

Annotate

//kanna:table opts a struct in; everything else is inferred from the fields and overridden with orm tags where the inference is not what the schema says.

package model

import "time"

//kanna:table
type User struct {
	ID        int       // primary key by name
	Name      string    // column "name"
	Email     string    `orm:"email_address"` // explicit column name
	CreatedAt time.Time // set automatically on create
	Posts     []Post    `orm:"has_many,foreign_key:user_id"`
}

//kanna:table
type Post struct {
	ID     int
	UserID int
	Title  string
	User   *User `orm:"belongs_to,foreign_key:user_id"`
}

Generate

//go:generate go tool kanna-orm -source ./model -destination ./query

The destination must be a package of its own — generating into the model package would leave it uncompilable whenever the output goes stale.

Use

db := orm.New(sqlDB, orm.MySQL) // or orm.PostgreSQL

users, err := query.Users(db).Where("name LIKE ?", "A%").OrderBy("id").All(ctx)
posts, err := query.Posts(db).Preload("User").All(ctx)

err = db.Transaction(ctx, func(tx orm.Querier) error {
	return query.Users(tx).Create(ctx, &model.User{Name: "Alice"})
})

Tags

The first element of an orm tag is either a relation kind or a column name; everything after it is an option.

TagMeaning
(no tag)column inferred from the field name (CreatedAtcreated_at)
orm:"col_name"explicit column name
orm:"-"not a column
orm:",primary_key"primary key (default: the field named ID)
orm:",created_at" / orm:",updated_at"timestamp managed on create/update (default: fields named CreatedAt/UpdatedAt)
orm:"has_many,foreign_key:user_id"one-to-many; the field is a slice
orm:"has_one,foreign_key:user_id"one-to-one; the target holds the key
orm:"belongs_to,foreign_key:user_id"the owning side; this struct holds the key
orm:"many_to_many,join_table:user_tags,foreign_key:user_id,references:tag_id"via a join table

Table names are pluralized snake_case (UserProfileuser_profiles); //kanna:table name=people overrides one. Name inference is mechanical — there is deliberately no acronym dictionary, so a mixed-case name like OAuthToken takes its column from the tag. Anything malformed — an unknown option, a relation whose target generates no queries, a missing foreign key column — is a positioned error, not a silent skip.

Flags

FlagDescription
-source <pkg>source package to scan
-destination <dir>output directory for orm_gen.go
-package <name>generated package name (defaults to what the destination declares)
-checkverify the output is up to date instead of writing it

Example

examples/orm drives the generated queries end to end against real MySQL and PostgreSQL: scopes, joins, the three preload kinds, transactions, batch inserts, and upserts. CI regenerates it, runs it against both databases, and fails if the output would change.

kanna-i18n

Generates typed message constructors from a directory of locale files — and compiles the translations themselves into the output, so nothing is parsed or read at run time.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-i18n
go get github.com/go-kanna/kanna/i18n

Two lines, because this generator has a runtime half: both the generated code and the code calling Localize import github.com/go-kanna/kanna/i18n, which carries the CLDR plural rules, locale-aware number formatting, and language fallback that depend on run-time values.

Use

One file per language, named by its BCP 47 tag:

# locales/en.yaml
greeting: "Hello!"
hello: "Hello, {name}!"
items_count:
  plural:
    one: "You have {count} item."
    other: "You have {count} items."
total_price: "Total: {price:number}"
user:
  not_found: "User not found."
//go:generate go tool kanna-i18n

With the defaults, that one line reads locales/ and writes messages/i18n_gen.go: a constructor per message, a Localizer accessor, and the translations of every language as an embedded bundle.

Discovery is flat: one file defines one language, and subdirectories are never scanned. Nothing locale-shaped is skipped in silence — a subdirectory or unreadable file named like a locale (locales/en/, en.json) fails the run, and other non-locale files are skipped with a warning.

// Hello returns the "hello" message.
func Hello(name string) i18n.Message {
	return i18n.Message{Key: "hello", Args: []i18n.Arg{
		{Name: "name", Value: name},
	}}
}

// Localizer renders this package's messages in the compiled locale best
// matching tag.
func Localizer(tag language.Tag) i18n.Localizer {
	return bundle.Localizer(tag)
}

// bundle holds every locale this package was generated from.
var bundle = i18n.NewBundle("en",
	i18n.Catalog{Lang: "en", Entries: map[string]i18n.Entry{
		"hello": {Single: i18n.Template{{Text: "Hello, "}, {Param: "name"}, {Text: "!"}}},
		// ...
	}},
)

Calling it takes no setup at all:

en := messages.Localizer(language.English)
fmt.Println(en.Localize(messages.Hello("World")))

The default language (-default, en unless said otherwise) defines the constructor signatures; every other locale is validated against it at generation time. At run time, a message missing from the requested language falls back through the language's parents (en-GB falls back to en) and finally to the default language, and requesting a language that was never compiled in gets the default outright.

Locale files

The locale directory is flat — one file per language, no recursion — and the filename stem is the language: en.yaml, pt-BR.yaml, ja.toml. YAML (.yaml, .yml) and TOML (.toml) both work. Files whose stem is not a language tag are skipped with a warning, so a stray config.yaml does not fail the run.

Nested mappings become dot-joined keys, and keys become constructor names: user.not_found generates UserNotFound(). Keys and parameter names match [a-z][a-z0-9_]* per segment.

A message declares its plural forms under an explicit plural mapping, keyed by CLDR category (zero, one, two, few, many, other) and always defining other. The marker is explicit so intent is never guessed from shape: keys that merely happen to be named one or other are ordinary nesting, and the only name a locale file cannot use freely is a mapping-valued plural. The generated constructor takes count int first, and the count picks the variant under the rendering language's own plural rules — Japanese has no one form, and that is fine.

A plural group that skips forms its language does use gets a warning, because those counts silently render with other: Russian providing only other is missing one, few, and many, while Japanese providing only other is complete.

Placeholders

FormMeaning
{name}a string parameter
{name:int}a plain integer, rendered as-is — counts, IDs
{name:number}a float64, rendered with the locale's conventions: 1,234.56 in en, 1.234,56 in de
{{ and }}literal braces

A bare placeholder inherits an explicit kind annotated elsewhere in the same message; conflicting annotations are an error.

Validation

Errors fail the generation: a key a translation has but the default language does not, a plural group where the default has a plain message (or the reverse), a parameter the default language never mentions, and conflicting kind annotations across plural variants.

Missing translations are warnings, not errors, because the runtime falls back to the default language. The generated code never breaks when a locale lags behind; it renders the default until the translation lands.

Flags

FlagMeaning
-locales <dir>directory containing locale files (default: locales)
-default <lang>default language defining the generated signatures (default: en)
-destination <dir>output directory for the generated file (default: messages)
-package <name>package name of the generated file (default: base of the output directory)
-checkverify the output is up to date instead of writing it

Example

examples/i18n renders the same messages in English and Japanese — plurals, locale-formatted numbers, and a fallback for a missing translation — with zero run-time setup. CI regenerates it and fails if the output would change.

Development

make test        # unit tests
make lint        # golangci-lint
make examples    # regenerate every example, then build and run it

The repository is a Go workspace: go.work points the examples' tool directive at this checkout, so go generate inside an example runs the generator you have locally rather than a published version.

License

MIT

Contributors

mickamy

216 commits

Languages

Go

99.1%