kanna

kanna / value

kanna-value

v0.0.8 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release English

kanna-value は、値を 1 つ保持する構造体と validate メソッドから値オブジェクトを生成します。コンストラクタ、アクセサ、JSON と SQL のメソッドがそろい、値がどこから来ても、ルールを満たすものしか保持しません。定数を持つ string 型を、外部からは定数の値だけを読み込む enum にすることもできます。

kanna コードジェネレータファミリーの 1 つです。Go 1.26 以上が必要です。

インストール

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

ルールはパッケージ github.com/go-kanna/value/rule で書きます。上のコマンドで依存に追加されます。標準ライブラリ以外には依存しません。

使い方

非公開フィールドを 1 つ持つ構造体に値を保持し、ルールを validate メソッドとして書きます:

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 ./... を実行すると、同じディレクトリに value_gen.go が書き出されます:

// 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

フィールドは非公開なので、パッケージの外で値を得る手段は NewEmail、MustEmail、UnmarshalJSON、Scan だけで、どれも validate を実行します。Email を受け取るコードは、もう一度チェックする必要がありません。

値オブジェクトは独立した型なので、エラーやドキュメントには付けた名前のまま現れ、他の型と同じようにメソッドを定義できます:

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

値オブジェクトにできない構造体は、実行時に値を扱う段階まで持ち越さず、生成の時点で止まります:

$ 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"?

生成されるものは、生成前から呼び出していても構いません。型を kanna-value に移行するときや、呼び出し側を先に書くときなどです。kanna-value は読み込んだパッケージを、これから書き出す内容を組み込んだ状態でもう一度型チェックし、コンパイルが通らない間は何も書き出しません。コンストラクタがないために隠れていた間違い、たとえば値オブジェクトにないメソッドの呼び出しも、このとき報告されます。

値オブジェクトを読み取る別のジェネレータのパッケージ(kanna-mapper のパッケージなど)には、その value_gen.go が存在している必要があります。そのため初回の実行では、値オブジェクトのパッケージを先に生成してください。

生成されるもの

フィールド t に型 T の値を保持する構造体 X について、次のものが生成されます:

宣言 内容
NewX(v T) (X, error) validate が受け入れれば v を保持する X を、拒否すればそのエラーを返す
MustX(v T) X 定数など、有効だと分かっている値のための NewX。v が拒否されると panic する
アクセサ 値を返す。名前は T から決まる: string は String、int64 は Int64、time.Time は Time、uuid.UUID は UUID
String() string 値に String があればそれを、なければ fmt の %v を使う。string の場合はアクセサそのもの
IsZero() bool 値に IsZero があればそれを、なければゼロ値との == を、スライスとマップでは == nil を使う
Equal(y X) bool time.Time のように T が Equal(T) bool を持つ場合のみ生成される
MarshalJSON, UnmarshalJSON T と同じようにエンコードする。T としてデコードし、validate が受け入れた場合のみ保持する
Value, Scan database/sql が T を変換するのと同じように変換する。T としてスキャンし、validate が受け入れた場合のみ保持する

非公開の構造体には、非公開のコンストラクタ newX と mustX が生成されます。

T によって、次のことが決まります:

  • T は基本型か名前付き型で、その名前からアクセサの名前が決まります。スライスは type tags []string のように独自の型を通して保持します。
  • スライスとマップは、受け取るときと返すときに slices.Clone または maps.Clone でコピーされるので、validate を通った後に変更されることはありません。ただし、要素の中身まではコピーされず、構造体の中のスライスもコピーされません。その場で変更されない値を保持してください。
  • IsZero は Go と同じ基準でゼロ値を判定します。nil スライスはゼロ値ですが、空のスライスは違います。== でも == nil でも独自のメソッドでもゼロ値を判定できない T(スライスを持つ構造体など)には IsZero が生成されず、encoding/json の omitzero はリフレクションにフォールバックします。
  • ゼロを渡すことと何も渡さないことは区別されません。価格の 0 のようにルールがゼロ値を受け入れる場合、MustPrice(0) は Price{} と等しく、IsZero はどちらもゼロと判定し、omitzero はどちらも省略し、kanna-validate の required はどちらも blank と報告します。ゼロという値と値がないことを区別したい場合は、*Price で保持し、値がないときは nil にしてください。
  • Equal があるのは、時刻では == が正しく判定できないことがあるからです。同じ時刻でも、片方がモノトニッククロックの読み取り値を持っていたり、ロケーションが異なっていたりすると == では異なるものになります。データベースから読み戻した時刻がこれにあたります。

デコードとスキャンは、エラーの場合も JSON の null の場合も、値を変更しません。NULL のカラムはエラーになります。NULL になりうるカラムは *X にスキャンしてください。その場合 database/sql が nil を設定します。

生成される名前と同じ名前のメソッドやコンストラクタを自分で書いている場合は、上書きせずに生成を止めます。生成されない場合の Equal と IsZero は、自分で書いて構いません。

Enum

宣言した定数の値だけを取る string 型には、//kanna:enum を付けます:

//kanna:enum
type OrderStatus string

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

go generate ./... を実行すると、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

これらはいずれも、どの定数でもない値を rule.Fail("inclusion") で拒否し(kanna-validate の oneof が報告するのと同じコードです)、値を変更しません。NULL のカラムは、値オブジェクトと同じくエラーになります。

型は定数を持つ string 型のままなので、定数に対する switch、switch の網羅性を調べる linter、定数を読み取る他のジェネレータは、これまでどおり動きます。値をエンコードするコードは生成されません。encoding/json も database/sql も、もともと string としてそのまま送るからです。生成されたコードがチェックするのは外部から来る値です。プログラム内では、他の string 型と同じく OrderStatus("shiped") もコンパイルが通るので、値は定数か NewOrderStatus で作ってください。

生成できない enum は、生成を止めます:

$ 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

非公開の enum には、非公開の関数 newX と xValues が生成されます。

ルール

validate メソッドは、値を受け入れるときは nil を、拒否するときは error を返します。ルールを書くためのものは、パッケージ rule にあります:

宣言 拒否するときの内容
rule.Fail(code), rule.FailWith(code, params) 指定したコードとパラメータを持つ *rule.Error
rule.Email(s) s が a@example.com のような素のアドレスでなければ email
rule.URL(s) s が https://example.com のようなホスト付きの絶対 URL でなければ url
rule.UUID(s) s が標準のテキスト形式の UUID でなければ uuid。バージョンは問わない

ルールはただの関数なので、普通の Go のコードとして組み合わせられます:

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
}

コードは破られたルールの名前で、翻訳メッセージや API レスポンスのキーに使えるほど安定した形式です。パラメータはメッセージに必要な情報を運びます。kanna-validate は count 付きの too_long のように Rails の ActiveModel::Validations のコードを報告するので、独自のルールでも同じコードを使うと API のエラーがそろいます。エラーは rule: too_long (count=3) のような形式になります。拒否された値は決して含まれません。エラーはログに残るものであり、値は個人情報かもしれないからです。

ルールを変更するとき

UnmarshalJSON がリクエストをルールでチェックするように、Scan は行を現在のルールでチェックします。そのため、ルールを厳しくすると、保存済みの行の読み取りに影響します。古いルールが受け入れて新しいルールが拒否する行はスキャンに失敗し、その行を含むクエリも一緒に失敗します。このような変更をリリースする前に、拒否されることになる行を見つけて修正または移動し、それから厳しくしたルールをデプロイしてください。enum から定数を削除する場合も同じです。定数を追加する場合も逆方向に同じことが起こります。新しい値の行ができた後にリリースをロールバックすると、その行を読み取れません。

そのような行をそのまま読まずに失敗させるのは、値オブジェクトの性質によるものです。データベースも含め、パッケージの外から来る値は、常にルールが受け入れる値だからです。

kanna-validate との連携

kanna-validate は、リクエストのフィールドをタグでチェックします。値オブジェクトは自分の形式を自分で守るので、そのフィールドに必要なのはせいぜい required だけです。kanna-validate は required を IsZero でチェックします:

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

validate.DecodeJSON は、UnmarshalJSON が拒否した値をコードとパラメータとともにフィールドのパスに記録し、デコードを続けます。そのため、レスポンスにはタグで見つかったエラーと一緒に並びます:

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

enum にも oneof は不要です。UnmarshalText が拒否した status は {"errors": {"status": [{"error": "inclusion"}]}} として記録されます。

Lint

wrapcheck は、validate メソッド内の return rule.Email(s) を、別パッケージからのラップされていないエラーとして報告します。このエラーはルールの判定結果で、ラップすべき原因はないので、パッケージを除外してください:

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

フラグ

kanna-value [flags] [packages]
フラグ 意味
-tags <list> カンマ区切りのビルドタグ
-check 書き出す代わりに、出力が最新であることを検証する。不要な出力が残っていないことも含む
-v 書き出した、削除した、変更しなかったファイルを一覧表示する
-version バージョンを表示する

パッケージのデフォルトはカレントディレクトリのパッケージなので、//go:generate go tool kanna-value に引数は不要です。成功した実行は、警告以外何も出力しません。警告の 1 つは実行そのものについてのものです。対象のパッケージに //kanna:value も //kanna:enum もない場合、残っている //go:generate の行は無害なので実行は成功しますが、その行も消すつもりだった可能性が高いので、そのことを知らせます:

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

型のディレクティブがすべてなくなったパッケージの value_gen.go は、実行時に読み込んだパッケージのコードがその宣言をまだ呼び出していない限り、削除されます。同じ名前でも生成コードのヘッダがないファイルには触れません。削除を行う go generate は open ...: no such file or directory で終わります。これは go generate 自身のエラーで、ジェネレータを実行する前にパッケージのファイルを列挙しているためです。ファイルは意図どおり削除されています(golang/go#36068)。

サンプル

example は、タスクサービスの値を定数と入力から作り、それらを持つリクエストをデコードします。リクエストは、有効なもの、タグが多すぎるもの、サービス開始より前に完了したもの、enum のどの定数でもない優先度を持つものの 4 つです。さらに、渡したスライスを外部から変更できないことも示します。CI はこれを再生成し、出力が変わる場合は失敗します。

開発

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

ライセンス

MIT