2️⃣

Go: 構造体の定義からフィールド値でフィルタできるイテレータを自動生成するCLIを作ってみた(miyamo2/filtgen)

2024/08/28に公開

30秒でなんとなくわかる概要

タイトルの通り今回も作ってみた系です
早速どんなものを作ったのかサクッと書いていくので実際にTryしていただける方は以降のセクションは飛ばしてもらってもよいかも

https://github.com/miyamo2/filtgen

インプットとなる構造体です

package main

import (
	"time"
)

type User struct {
	ID         string     `filtgen:"eq,ne"`
	Name       string     `filtgen:"eq"`
	Age        int        `filtgen:"eq,ge,le"`
	Disabled   bool       `filtgen:"eq"`
	LastActive time.Time  `filtgen:"matches"`
}

filtgen generateコマンドで先ほどの構造体を食べさせます

filtgen generate -s user.go

以下のようなdefined-typeと

type UserSlice []User

type UserMap[T comparble] map[T]User

type UserSeq iter.Seq[User]

type UserSeq2[T any] iter.Seq2[T]

以下のようなメソッドが生成されます
同じシグニチャのメソッドがUserMap, UserSeq, UserSeq2でもそれぞれ生成されますがレシーバ以外は基本的に一緒です
ただしUserSeqのみ戻り値の型が若干異なります

func(s UserSlice) IDEq(str string) UserSeq2 { ... }

func(s UserSlice) IDNe(str string) UserSeq2 { ... }

func(s UserSlice) NameEq(str string) UserSeq2 { ... }

func(s UserSlice) AgeEq(i int) UserSeq2 { ... }

func(s UserSlice) AgeGe(i int) UserSeq2 { ... }

func(s UserSlice) AgeLe(i int) UserSeq2 { ... }

func(s UserSlice) DisableEq(b bool) UserSeq2 { ... }

func(s UserSlice) LastActiveMatches(matcher func(time.Time) bool) UserSeq2 { ... }

使う側のコードはこんな感じです

var s []User = getUsers()

for i, v := range UserSlice(s).IDEq("ABCD12345") {
    fmt.Printf("%d: %v\n", i, v)
}

for i, v := range UserSlice(s).NameEq("Bob") {
    fmt.Printf("%d: %v\n", i, v)
}

for i, v := range UserSlice(s).AgeGe(20) {
    fmt.Printf("%d: %v\n", i, v)
}

for i, v := range UserSlice(s).LastActiveMatches(func(t time.Time) bool { return t.Before(time.Now().Add(-time.Year)) }) {
    fmt.Printf("%d: %v\n", i, v)
}

// メソッドチェインでも書けます
for i, v := range UserSlice(s).Disabled(false).LastActiveMatches(func(t time.Time) bool { return t.Before(time.Now().Add(-time.Year)) }) {
    fmt.Printf("%d: %v\n", i, v)
}

生成手順

インストール

go install github.com/miyamo2/filtgen@latest

使い方

ステップ1: 構造体にfiltgenタグを設定する

フィルタ機能を適用したいフィールドにfiltgenタグを設定してください
filtgenタグについては後述します

package main

import (
	"time"
)

type Foo struct {
	StringField     string     `filtgen:"*"`
	IntField        int        `filtgen:"*"`
	BoolField       bool       `filtgen:"*"`
	TimeField       time.Time  `filtgen:"*"`
	ErrorField      error      `filtgen:"*"`
}

任意でgo:generateディレクティブを設定することも可能です

//go:generate filtgen generate -s $GOFILE
package main

ステップ2: filtgen generateの実行

filtgenのサブコマンド、generateでコード生成を行います
--source(-s)は対象ファイルを指定するためのフラグで必須です

filtgen generate -s your_struct.go

ステップ1でgo:generateディレクティブを設定した方はいつものgo generateでいけます
個人的にはこちらをおすすめしたい

go generate ./...

ステップ3: 生成されたコードを使ってみる

errSomething := errors.New("something")

s := []Foo{
    {StringField: "a", IntField: 1, BoolField: true, TimeField: time.Now()},
    {StringField: "b", IntField: 2, BoolField: false, TimeField: time.Now(), ErrorField: errSomething},
    {StringField: "c", IntField: 3, BoolField: true, TimeField: time.Now().Add(-(time.Hour * 2))},
}

for i, v := range FooSlice(s).StringFieldGe("a") {
    fmt.Printf("%d: %s\n", i, v.StringField)
}
// Output: 0: a
// 1: b
// 2: c

for i, v := range FooSlice(s).IntFieldGt(1) {
    fmt.Printf("%d: %s\n", i, v.StringField)
}
// Output: 1: b
// 2: c

for i, v := range FooSlice(s).BoolFieldEq(true) {
    fmt.Printf("%d: %s\n", i, v.StringField)
}
// Output: 0: a
// 2: c

for i, v := range FooSlice(s).TimeFieldMatches(func(t time.Time) bool { return t.Before(time.Now().Add(-time.Hour)) }) {
    fmt.Printf("%d: %s\n", i, v.StringField)
}
// Output: 2: c

for i, v := range FooSlice(s).ErrorFieldIs(errSomething) {
    fmt.Printf("%d: %s\n", i, v.StringField)
}
// Output: 1: b

実際にfiltgenで生成したコードは exampleをご覧ください

filtgenが生成するコード

filtgenでは以下のdefined-typeが生成されます

  • XxxSlice([]T)

  • XxxMap[U](map[U compareble]T)

  • XxxSeq[T](iter.Seq[T])

  • XxxSeq2[U](iter.Seq2[U, T])

型の名前は対象の構造体に応じてUpperCamelで解決されます
e.g. User -> UserSlice.

フィルタを利用する場合、slice, map, iter.Seqもしくはiter.Seq2をそれぞれ対応するdefined-typeにキャストをしてください

s := []User{
    {Name: "Alice"},
    {Name: "Bob"},
}
for i, v := range UserSlice(s).NameEq("Alice") {
    fmt.Printf("%d: %s\n", i, v.Name)
}

メソッド

filtgenでは以下のメソッドを生成することが可能です
ただし、フィールドの型によっては生成できないメソッドもあるので要注意です
型/メソッドの対応一覧についてはこちらを参照してください

メソッド名はフィールド名に応じてUpperCamelで解決されます
e.g. Name -> NameEq.

XxxEq

イテレータを引数と等価な項目のみにフィルタします
stringの場合はstrings.Compareによって判断

type User struct {
    Name string `filtgen:"eq"`
}
for i, v := range UserSlice(s).NameEq("Alice") {
    fmt.Printf("%d: %s\n", i, v.Name)
}

XxxNe

イテレータを引数と等価でない項目のみにフィルタします
stringの場合はstrings.Compareによって判断

type User struct {
    Name string `filtgen:"ne"`
}
for i, v := range UserSlice(s).NameNe("Alice") {
    fmt.Printf("%d: %s\n", i, v.Name)
}

XxxGt

イテレータを引数より大きい項目のみにフィルタします
stringの場合はstrings.Compareによって判断

type User struct {
    Name string `filtgen:"gt"`
}
for i, v := range UserSlice(s).NameGt("Alice") {
    fmt.Printf("%d: %s\n", i, v.Name)
}

XxxLt

イテレータを対象のフィールドが引数より小さい項目のみにフィルタします
stringの場合はstrings.Compareによって判断

type User struct {
    Name string `filtgen:"lt"`
}
for i, v := range UserSlice(s).NameLt("Alice") {
    fmt.Printf("%d: %s\n", i, v.Name)
}

XxxGe

イテレータを対象のフィールドが引数と等価か引数より大きい項目のみにフィルタします
stringの場合はstrings.Compareによって判断

type User struct {
    Name string `filtgen:"ge"`
}
for i, v := range UserSlice(s).NameGe("Alice") {
    fmt.Printf("%d: %s\n", i, v.Name)
}

XxxLe

イテレータを対象のフィールドが引数と等価か引数より小さい項目のみにフィルタします
stringの場合はstrings.Compareによって判断

type User struct {
    Name string `filtgen:"le"`
}
for i, v := range UserSlice(s).NameLe("Alice") {
    fmt.Printf("%d: %s\n", i, v.Name)
}

XxxMatches

イテレータを対象のフィールドが引数の関数でtrueを返す項目のみにフィルタします

type User struct {
    Name string `filtgen:"matches"`
}
for i, v := range UserSlice(s).NameMatches(func(s string) bool { return strings.HasPrefix(s, "A") }) {
    fmt.Printf("%d: %s\n", i, v.Name)
}

XxxIs

イテレータを対象のフィールドが引数のエラーと一致する項目のみにフィルタします
errors.Isによって判断

type Transaction struct {
    ID   string `filtgen:"eq"`
    Err  error  `filtgen:"is"`
}
for i, v := range TransactionSlice(s).ErrIs(fmt.Errorf("something")) {
    fmt.Printf("%d: %s\n", i, v.ID)
}

XxxIsnt

イテレータを対象のフィールドが引数のエラーと一致しない項目のみにフィルタします
errors.Isによって判断

type Transaction struct {
    ID   string `filtgen:"eq"`
    Err  error  `filtgen:"isnt"`
}
for i, v := range TransactionSlice(s).ErrIsnt(fmt.Errorf("something")) {
    fmt.Printf("%d: %s\n", i, v.ID)
}

型ごとの対応メソッド一覧

型╲メソッド XxxEq XxxNe XxxGt XxxLt XxxGe XxxLe XxxMatches XxxIs XxxIsnt
string
int
int8
int16
int32
int64
uint
uint8
uint16
uint32
uint64
float32
float64
complex64
complex128
byte
rune
error
bool
その他

filtgenタグ

filtgenタグには以下の値が設定できます
複数指定したい場合は,区切りで記述できます

type A struct {
    StringField string `filtgen:"eq,ne"`
}
説明
* フィールドの型が対応するすべてのメソッドを生成する。
eq XxxEqメソッドを生成する。
ne XxxNeメソッドを生成する。
gt XxxGtメソッドを生成する。
lt XxxLtメソッドを生成する。
ge XxxGeメソッドを生成する。
le XxxLeメソッドを生成する。
matches XxxMatchesメソッドを生成する。
is XxxIsメソッドを生成する。
isnt XxxIsntメソッドを生成する。

おわりに

元ネタを読み込む処理にastを使っているのですが他言語含めastを触るのは初めてだったので刺激的でした
出力部分にはtext/templateを使っているので今後テンプレートをいい感じに分割していきたいです

filtgenが気になった方や気に入ってくれた方はstarを、バグや改善案を見つけてくれた方はイシューを、共鳴してくれた方はPRをいただけると励みになります

Discussion