kanna

kanna / value

kanna-value

v0.0.8 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release 日本語

kanna-value turns a struct holding one value and a validate method into a value type — constructors, an accessor, and JSON and SQL methods — that holds only what its rule accepts, wherever the value comes from. It also turns a string type with constants into an enum that reads from outside only the values of its constants.

Part of the kanna family of code generators. Requires Go 1.26.

Install

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

Rules are written with the package github.com/go-kanna/value/rule, which the line above already requires. It depends on nothing outside the standard library.

Use

Hold the value in a struct with one unexported field, and write its rule as a validate method:

package model

import "github.com/go-kanna/value/rule"

//go:generate go tool kanna-value

//kanna:value
type Email struct{ s string }

func (Email) validate(s string) error { return rule.Email(s) }

//kanna:value
type CompletedAt struct{ t time.Time }

func (CompletedAt) validate(t time.Time) error {
	if t.Before(opened) {
		return rule.FailWith("greater_than_or_equal_to", map[string]any{"count": opened})
	}
	return nil
}

go generate ./... writes value_gen.go next to it:

// Code generated by kanna-value. DO NOT EDIT.

func NewEmail(v string) (Email, error)           // checks v with validate
func MustEmail(v string) Email                   // for a constant: panics if v is rejected
func (x Email) String() string                   // the string held
func (x Email) IsZero() bool
func (x Email) MarshalJSON() ([]byte, error)
func (x *Email) UnmarshalJSON(data []byte) error // checks what it decodes
func (x Email) Value() (driver.Value, error)
func (x *Email) Scan(src any) error              // checks what it scans

func NewCompletedAt(v time.Time) (CompletedAt, error)
func MustCompletedAt(v time.Time) CompletedAt
func (x CompletedAt) Time() time.Time
func (x CompletedAt) Equal(y CompletedAt) bool
// String, IsZero, MarshalJSON, UnmarshalJSON, Value, and Scan, as for Email

The field is unexported, so outside the package a value comes only from NewEmail, MustEmail, UnmarshalJSON, or Scan, and each of them runs validate. Code receiving an Email need not check it again.

A value type is a type of its own, named as you named it in errors and documentation, and it takes methods like any other:

func (e Email) Domain() string {
	_, domain, _ := strings.Cut(e.s, "@")
	return domain
}

A struct that cannot become a value type stops generation, rather than the first value that meets it:

$ go generate ./...
./model.go:6:6: error: Email has no validate method to check its values with
  add func (Email) validate(string) error, returning nil to accept the value
./model.go:9:17: error: ID.Value must be unexported, so that a value is set only through the generated code
  rename it to value
./model.go:14:19: error: Tags holds a []string, which has no name to call the method returning it after
  declare a named type for it, and hold that
./model.go:21:1: warning: "//kanna:valeu" is not recognized as a directive
  did you mean "//kanna:value"?

Code may call what is generated before it exists, as when a type moves to kanna-value or its callers are written first. kanna-value type-checks the packages it loads once more with what it is about to write in place, and writes nothing while their code does not compile with it: a mistake the missing constructor hid is reported then, such as a method that the value type does not have. Another package whose generator reads the value type, such as a kanna-mapper package, needs its value_gen.go to exist, so on that first run, generate the value type's package before it.

What is generated

For a struct X holding a value of type T in its field t:

Declaration What it does
NewX(v T) (X, error) returns the X holding v if validate accepts it, and its error otherwise
MustX(v T) X NewX for values known to be valid, such as constants; panics if v is rejected
accessor returns the value, named after T: String for a string, Int64 for an int64, Time for a time.Time, UUID for a uuid.UUID
String() string the value's own String if it has one, or else fmt's %v; for a string, the accessor itself
IsZero() bool the value's own IsZero if it has one, or else == with the zero value, or == nil for a slice or a map
Equal(y X) bool only when T has Equal(T) bool, as time.Time does
MarshalJSON, UnmarshalJSON encode the value as T encodes; decode a T and keep it only if validate accepts it
Value, Scan convert the value as database/sql converts a T; scan a T and keep it only if validate accepts it

An unexported struct gets unexported constructors, newX and mustX.

A few things follow from what T is:

  • T is a basic or a named type, after which the accessor is named. A slice is held through a type of its own, such as type tags []string.
  • A slice or a map is copied on the way in and on the way out, with slices.Clone or maps.Clone, so that nothing changes it past validate. What it holds is not copied in turn, nor is a slice inside a struct: hold values that are not changed in place.
  • IsZero tells the zero value as Go does: a nil slice is zero, an empty one is not. A T that neither == nor == nil nor a method of its own can tell zero, such as a struct holding a slice, gets no IsZero; encoding/json's omitzero falls back on reflection for it.
  • A zero given is the zero never given: when the rule accepts the zero value, as a price of 0, MustPrice(0) equals Price{}, IsZero reports both, omitzero leaves both out, and kanna-validate's required reports both blank. Where a value of zero and no value at all mean different things, hold a *Price, nil when none was given.
  • Equal exists because == can be wrong for a time: two of the same instant differ when one carries a monotonic clock reading or another location, as a time read back from a database does.

Decoding and scanning leave the value as it was on any error, and so does JSON null. A NULL column is an error: scan a column that may be NULL into a *X, which database/sql sets to nil.

A method or a constructor you have written under one of the generated names stops generation instead of being overwritten. Equal and IsZero are yours to write when none is generated.

Enums

Mark a string type whose values are the constants declared beside it with //kanna:enum:

//kanna:enum
type OrderStatus string

const (
	OrderStatusPlaced   OrderStatus = "placed"
	OrderStatusPaid     OrderStatus = "paid"
	OrderStatusShipped  OrderStatus = "shipped"
	OrderStatusCanceled OrderStatus = "canceled"
)

go generate ./... adds to value_gen.go:

func NewOrderStatus(v string) (OrderStatus, error)    // OrderStatus(v), if it is the value of a constant
func OrderStatusValues() []OrderStatus                 // the constants in the order they are declared, in a new slice
func (x *OrderStatus) UnmarshalText(text []byte) error // what encoding/json decodes a string or an object key with
func (x *OrderStatus) Scan(src any) error

Each of them rejects a value that is none of the constants with rule.Fail("inclusion"), the code kanna-validate's oneof reports too, and leaves the value as it was. A NULL column is an error, as for a value type.

The type stays a string type with constants, so a switch over them, linters checking that a switch is exhaustive, and other generators reading the constants work as before. Nothing is generated to encode a value, as encoding/json and database/sql already send it as the string it is. What the generated code checks is what comes from outside: within the program, OrderStatus("shiped") compiles as for any string type, so make values with the constants or with NewOrderStatus.

An enum that cannot be generated stops generation:

$ go generate ./...
./order.go:5:6: error: OrderStatus has no constants to be one of
  declare them beside it, such as const OrderStatusA OrderStatus = "a"
./order.go:12:2: error: OrderStatusSettled holds "paid", as OrderStatusPaid does
  give each constant a value of its own
./order.go:13:2: error: OrderStatusNone holds "", the zero value of OrderStatus, which stands for none
  remove it, and use a *OrderStatus where a value may be absent
./order.go:20:6: error: Kind is not a struct, which //kanna:value marks
  mark it //kanna:enum to accept only the constants declared beside it

An unexported enum gets unexported functions, newX and xValues.

Rules

A validate method returns nil to accept a value, and an error to reject it. The package rule provides what rules are written with:

Declaration Rejects with
rule.Fail(code), rule.FailWith(code, params) an *rule.Error with your code and params
rule.Email(s) email, unless s is a bare address such as a@example.com
rule.URL(s) url, unless s is an absolute URL with a host, such as https://example.com
rule.UUID(s) uuid, unless s is a UUID in its standard textual form, of any version

A rule is a plain function, so rules combine as any Go code does:

func (CompanyEmail) validate(s string) error {
	if err := rule.Email(s); err != nil {
		return err
	}
	if !strings.HasSuffix(s, "@example.com") {
		return rule.Fail("corp_domain")
	}
	return nil
}

The code names the broken rule in a form stable enough to key a translated message or an API response, and the params carry what the message needs. kanna-validate reports the codes of Rails' ActiveModel::Validations, such as too_long with a count; using them for your own rules keeps an API's errors alike. An error formats as rule: too_long (count=3). The rejected value is never part of it: errors end up in logs, and the value may be personal data.

Changing a rule

Scan holds a row to the rule as it is now, as UnmarshalJSON holds a request, so a rule made stricter changes what the rows already stored can be read as: a row the old rule accepted and the new one rejects fails to scan, and a query listing it fails with it. Before such a change goes out, find the rows it would reject, mend or move them, and then deploy the stricter rule. An enum losing a constant is the same change. One gaining a constant is too, the other way round: a release rolled back after rows took the new value cannot read them.

That such a row fails rather than being read as it is follows from what a value type is: a value from outside the package, a database included, is always one its rule accepts.

With kanna-validate

kanna-validate checks the fields of a request by their tags. A value type takes care of its own format, so its field needs at most required, which kanna-validate checks with IsZero:

//kanna:validate
type SignUp struct {
	Name  string      `json:"name"  validate:"required"`
	Email model.Email `json:"email" validate:"required"`
}

validate.DecodeJSON records a value that UnmarshalJSON rejects at its field's path, with its code and params, and goes on decoding, so the response lists it together with whatever the tags find:

{"errors": {"email": [{"error": "email"}], "name": [{"error": "blank"}]}}

An enum needs no oneof either: a status that UnmarshalText rejects is recorded as {"errors": {"status": [{"error": "inclusion"}]}}.

Linting

wrapcheck reports return rule.Email(s) in a validate method as an unwrapped error from another package. The error is the rule's verdict, with nothing underneath to wrap, so let the package through:

linters:
  settings:
    wrapcheck:
      ignore-package-globs:
        - github.com/go-kanna/value/rule

Flags

kanna-value [flags] [packages]
Flag Meaning
-tags <list> comma-separated build tags
-check verify the output is up to date instead of writing it, including that no output is left over
-v list the files written, removed, or left unchanged
-version print the version

The packages default to the one in the current directory, so //go:generate go tool kanna-value needs no argument. A run that succeeds prints nothing but its warnings. One of them is about the run itself: when nothing in its packages is marked //kanna:value or //kanna:enum, it succeeds, since a //go:generate line left behind does no harm, but says so, since the line was most likely meant to go as well:

$ go generate ./...
warning: nothing is marked //kanna:value or //kanna:enum in example.com/app/model

A package whose types have all lost their directive has its value_gen.go removed, unless code in the packages the run loads still calls what it declares; a file of that name without the generated-code header is never touched. The go generate that removes it ends with open ...: no such file or directory, from go generate itself, which lists a package's files before it runs the generators; the file is gone as it should be (golang/go#36068).

Example

example builds the values of a task service from constants and from input, decodes requests holding them — one valid, one with too many tags, one completed before the service began, one with a priority that is none of its enum's constants — and shows that a slice it was given cannot be changed from outside. CI regenerates it and fails if the output would change.

Development

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

License

MIT