diff --git a/README.md b/README.md index 25718fd..5ae8d3f 100644 --- a/README.md +++ b/README.md @@ -323,3 +323,30 @@ func (r Role) HasPermission() bool { return r == RoleMod || r == RoleAdmin } ``` + + +## Performance + +While it's true that the `xybor-x/enum` approach will generally be slower than the code generation approach, I still want to highlight the difference. + +The benchmark results are based on defining an enum with 10 values at [bench](./bench). + +| | `xybor-x/enum` | Code generation | +| --------------- | -------------: | --------------: | +| ToString | 17 ns | 6 ns | +| FromString | 22 ns | 15 ns | +| json.Marshal | 148 ns | 113 ns | +| json.Unmarshal | 144 ns | 147 ns | +| SQL Value | 38 ns | 29 ns | +| SQL Scan bytes | 41 ns | 29 ns | +| SQL Scan string | 22 ns | 15 ns | + + +## V2 roadmap + +- Switch from `json.Marshaler` to `encoding.TextMarshaler` for better performance. +- New function allocates from 1 instead 0. +- Allow defining which enum supports default value or nil value. +- Remove deprecated items. + +**Contributions and suggestions to refine the roadmap are welcome!** diff --git a/bench/bench_test.go b/bench/bench_test.go new file mode 100644 index 0000000..10a3345 --- /dev/null +++ b/bench/bench_test.go @@ -0,0 +1,119 @@ +package bench_test + +import ( + "encoding/json" + "testing" + + "github.com/xybor-x/enum" + "github.com/xybor-x/enum/bench" +) + +func BenchmarkGen10ToString(b *testing.B) { + b.Run("Gen", func(b *testing.B) { + enum := bench.GenEnumTypeT9 + for i := 0; i < b.N; i++ { + _ = enum.String() + } + }) + + b.Run("XyborX", func(b *testing.B) { + enum := bench.XyborEnumTypeT9 + for i := 0; i < b.N; i++ { + _ = enum.String() + } + }) +} + +func BenchmarkGen10FromString(b *testing.B) { + b.Run("Gen", func(b *testing.B) { + for i := 0; i < b.N; i++ { + bench.ParseGenEnumType("t9") + } + }) + + b.Run("XyborX", func(b *testing.B) { + for i := 0; i < b.N; i++ { + enum.FromString[bench.XyborEnumType]("t9") + } + }) +} + +func BenchmarkGen10JsonMarshal(b *testing.B) { + b.Run("Gen", func(b *testing.B) { + enum := bench.GenEnumTypeT9 + for i := 0; i < b.N; i++ { + json.Marshal(enum) + } + }) + + b.Run("XyborX", func(b *testing.B) { + enum := bench.XyborEnumTypeT9 + for i := 0; i < b.N; i++ { + json.Marshal(enum) + } + }) +} + +func BenchmarkGen10JsonUnmarshal(b *testing.B) { + b.Run("Gen", func(b *testing.B) { + var enum bench.GenEnumType + for i := 0; i < b.N; i++ { + json.Unmarshal([]byte(`"t9"`), &enum) + } + }) + + b.Run("XyborX", func(b *testing.B) { + var enum bench.XyborEnumType + for i := 0; i < b.N; i++ { + json.Unmarshal([]byte(`"t9"`), &enum) + } + }) +} + +func BenchmarkGen10SqlValue(b *testing.B) { + b.Run("Gen", func(b *testing.B) { + enum := bench.GenEnumTypeT9 + for i := 0; i < b.N; i++ { + enum.Value() + } + }) + + b.Run("XyborX", func(b *testing.B) { + enum := bench.XyborEnumTypeT9 + for i := 0; i < b.N; i++ { + enum.Value() + } + }) +} + +func BenchmarkGen10SqlScanByte(b *testing.B) { + b.Run("Gen", func(b *testing.B) { + var enum bench.GenEnumType + for i := 0; i < b.N; i++ { + enum.Scan([]byte(`t9`)) + } + }) + + b.Run("XyborX", func(b *testing.B) { + var enum bench.XyborEnumType + for i := 0; i < b.N; i++ { + enum.Scan([]byte(`t9`)) + } + }) +} + +func BenchmarkSqlScanString(b *testing.B) { + b.Run("Gen", func(b *testing.B) { + var enum bench.GenEnumType + for i := 0; i < b.N; i++ { + enum.Scan("t9") + } + }) + + b.Run("XyborX", func(b *testing.B) { + var enum bench.XyborEnumType + for i := 0; i < b.N; i++ { + enum.Scan("t9") + } + }) +} diff --git a/bench/gen.go b/bench/gen.go new file mode 100644 index 0000000..4f0535d --- /dev/null +++ b/bench/gen.go @@ -0,0 +1,6 @@ +package bench + +//go:generate go-enum --marshal --sql + +// ENUM(t0, t1, t2, t3, t4, t5, t6, t7, t8, t9) +type GenEnumType int diff --git a/bench/gen_enum.go b/bench/gen_enum.go new file mode 100644 index 0000000..88bab6a --- /dev/null +++ b/bench/gen_enum.go @@ -0,0 +1,178 @@ +// Code generated by go-enum DO NOT EDIT. +// Version: 0.6.0 +// Revision: 919e61c0174b91303753ee3898569a01abb32c97 +// Build Date: 2023-12-18T15:54:43Z +// Built By: goreleaser + +package bench + +import ( + "database/sql/driver" + "errors" + "fmt" +) + +const ( + // GenEnumTypeT0 is a GenEnumType of type T0. + GenEnumTypeT0 GenEnumType = iota + // GenEnumTypeT1 is a GenEnumType of type T1. + GenEnumTypeT1 + // GenEnumTypeT2 is a GenEnumType of type T2. + GenEnumTypeT2 + // GenEnumTypeT3 is a GenEnumType of type T3. + GenEnumTypeT3 + // GenEnumTypeT4 is a GenEnumType of type T4. + GenEnumTypeT4 + // GenEnumTypeT5 is a GenEnumType of type T5. + GenEnumTypeT5 + // GenEnumTypeT6 is a GenEnumType of type T6. + GenEnumTypeT6 + // GenEnumTypeT7 is a GenEnumType of type T7. + GenEnumTypeT7 + // GenEnumTypeT8 is a GenEnumType of type T8. + GenEnumTypeT8 + // GenEnumTypeT9 is a GenEnumType of type T9. + GenEnumTypeT9 +) + +var ErrInvalidGenEnumType = errors.New("not a valid GenEnumType") + +const _GenEnumTypeName = "t0t1t2t3t4t5t6t7t8t9" + +var _GenEnumTypeMap = map[GenEnumType]string{ + GenEnumTypeT0: _GenEnumTypeName[0:2], + GenEnumTypeT1: _GenEnumTypeName[2:4], + GenEnumTypeT2: _GenEnumTypeName[4:6], + GenEnumTypeT3: _GenEnumTypeName[6:8], + GenEnumTypeT4: _GenEnumTypeName[8:10], + GenEnumTypeT5: _GenEnumTypeName[10:12], + GenEnumTypeT6: _GenEnumTypeName[12:14], + GenEnumTypeT7: _GenEnumTypeName[14:16], + GenEnumTypeT8: _GenEnumTypeName[16:18], + GenEnumTypeT9: _GenEnumTypeName[18:20], +} + +// String implements the Stringer interface. +func (x GenEnumType) String() string { + if str, ok := _GenEnumTypeMap[x]; ok { + return str + } + return fmt.Sprintf("GenEnumType(%d)", x) +} + +// IsValid provides a quick way to determine if the typed value is +// part of the allowed enumerated values +func (x GenEnumType) IsValid() bool { + _, ok := _GenEnumTypeMap[x] + return ok +} + +var _GenEnumTypeValue = map[string]GenEnumType{ + _GenEnumTypeName[0:2]: GenEnumTypeT0, + _GenEnumTypeName[2:4]: GenEnumTypeT1, + _GenEnumTypeName[4:6]: GenEnumTypeT2, + _GenEnumTypeName[6:8]: GenEnumTypeT3, + _GenEnumTypeName[8:10]: GenEnumTypeT4, + _GenEnumTypeName[10:12]: GenEnumTypeT5, + _GenEnumTypeName[12:14]: GenEnumTypeT6, + _GenEnumTypeName[14:16]: GenEnumTypeT7, + _GenEnumTypeName[16:18]: GenEnumTypeT8, + _GenEnumTypeName[18:20]: GenEnumTypeT9, +} + +// ParseGenEnumType attempts to convert a string to a GenEnumType. +func ParseGenEnumType(name string) (GenEnumType, error) { + if x, ok := _GenEnumTypeValue[name]; ok { + return x, nil + } + return GenEnumType(0), fmt.Errorf("%s is %w", name, ErrInvalidGenEnumType) +} + +// MarshalText implements the text marshaller method. +func (x GenEnumType) MarshalText() ([]byte, error) { + return []byte(x.String()), nil +} + +// UnmarshalText implements the text unmarshaller method. +func (x *GenEnumType) UnmarshalText(text []byte) error { + name := string(text) + tmp, err := ParseGenEnumType(name) + if err != nil { + return err + } + *x = tmp + return nil +} + +var errGenEnumTypeNilPtr = errors.New("value pointer is nil") // one per type for package clashes + +// Scan implements the Scanner interface. +func (x *GenEnumType) Scan(value interface{}) (err error) { + if value == nil { + *x = GenEnumType(0) + return + } + + // A wider range of scannable types. + // driver.Value values at the top of the list for expediency + switch v := value.(type) { + case int64: + *x = GenEnumType(v) + case string: + *x, err = ParseGenEnumType(v) + case []byte: + *x, err = ParseGenEnumType(string(v)) + case GenEnumType: + *x = v + case int: + *x = GenEnumType(v) + case *GenEnumType: + if v == nil { + return errGenEnumTypeNilPtr + } + *x = *v + case uint: + *x = GenEnumType(v) + case uint64: + *x = GenEnumType(v) + case *int: + if v == nil { + return errGenEnumTypeNilPtr + } + *x = GenEnumType(*v) + case *int64: + if v == nil { + return errGenEnumTypeNilPtr + } + *x = GenEnumType(*v) + case float64: // json marshals everything as a float64 if it's a number + *x = GenEnumType(v) + case *float64: // json marshals everything as a float64 if it's a number + if v == nil { + return errGenEnumTypeNilPtr + } + *x = GenEnumType(*v) + case *uint: + if v == nil { + return errGenEnumTypeNilPtr + } + *x = GenEnumType(*v) + case *uint64: + if v == nil { + return errGenEnumTypeNilPtr + } + *x = GenEnumType(*v) + case *string: + if v == nil { + return errGenEnumTypeNilPtr + } + *x, err = ParseGenEnumType(*v) + } + + return +} + +// Value implements the driver Valuer interface. +func (x GenEnumType) Value() (driver.Value, error) { + return x.String(), nil +} diff --git a/bench/go.mod b/bench/go.mod new file mode 100644 index 0000000..3a01b51 --- /dev/null +++ b/bench/go.mod @@ -0,0 +1,3 @@ +module github.com/xybor-x/enum/bench + +go 1.22.1 diff --git a/bench/go.sum b/bench/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/bench/xybor_x_enum.go b/bench/xybor_x_enum.go new file mode 100644 index 0000000..fb40846 --- /dev/null +++ b/bench/xybor_x_enum.go @@ -0,0 +1,32 @@ +package bench + +import "github.com/xybor-x/enum" + +type xyborEnumType int +type XyborEnumType = enum.WrapEnum[xyborEnumType] + +const ( + XyborEnumTypeT0 XyborEnumType = iota + XyborEnumTypeT1 + XyborEnumTypeT2 + XyborEnumTypeT3 + XyborEnumTypeT4 + XyborEnumTypeT5 + XyborEnumTypeT6 + XyborEnumTypeT7 + XyborEnumTypeT8 + XyborEnumTypeT9 +) + +var ( + _ = enum.Map(XyborEnumTypeT0, "t0") + _ = enum.Map(XyborEnumTypeT1, "t1") + _ = enum.Map(XyborEnumTypeT2, "t2") + _ = enum.Map(XyborEnumTypeT3, "t3") + _ = enum.Map(XyborEnumTypeT4, "t4") + _ = enum.Map(XyborEnumTypeT5, "t5") + _ = enum.Map(XyborEnumTypeT6, "t6") + _ = enum.Map(XyborEnumTypeT7, "t7") + _ = enum.Map(XyborEnumTypeT8, "t8") + _ = enum.Map(XyborEnumTypeT9, "t9") +) diff --git a/enum.go b/enum.go index a3f6a6d..37306d4 100644 --- a/enum.go +++ b/enum.go @@ -12,7 +12,6 @@ package enum import ( "database/sql/driver" - "encoding/json" "fmt" "math" "path" @@ -35,8 +34,8 @@ type innerEnumable interface { // Map associates an enum with its numeric and string representations. If the // enum is a number, its value will be used as the numeric representation. -// Otherwise, the library automatically assigns the smallest positive integer -// number available to the enum. +// Otherwise, the library automatically assigns the smallest non-negative +// integer number available to the enum. // // Note that this function is not thread-safe and should only be called during // initialization or other safe execution points to avoid race conditions. @@ -64,7 +63,7 @@ func Map[Enum any](enum Enum, s string) Enum { // New creates a dynamic enum value. The Enum type must be a number, string, or // supported enums (e.g WrapEnum, SafeEnum). // -// The library automatically generates the smallest positive integer number +// The library automatically generates the smallest non-negative integer number // available as the numeric representation of enum. // // If the enum is @@ -246,24 +245,25 @@ func IsValid[Enum any](value Enum) bool { // MarshalJSON serializes an enum value into its string representation. func MarshalJSON[Enum any](value Enum) ([]byte, error) { - if !IsValid(value) { + s, ok := mtmap.Get2(mtkey.EnumToJSON(value)) + if !ok { return nil, fmt.Errorf("enum %s: invalid value %#v", TrueNameOf[Enum](), value) } - return json.Marshal(ToString(value)) + return []byte(s), nil } // UnmarshalJSON deserializes a string representation of an enum value from // JSON. -func UnmarshalJSON[Enum any](data []byte, t *Enum) error { - var str string - if err := json.Unmarshal(data, &str); err != nil { - return err +func UnmarshalJSON[Enum any](data []byte, t *Enum) (err error) { + n := len(data) + if n < 2 || data[0] != '"' || data[n-1] != '"' { + return fmt.Errorf("enum %s: invalid string %s", TrueNameOf[Enum](), string(data)) } - enum, ok := FromString[Enum](str) + enum, ok := mtmap.Get2(mtkey.String2Enum[Enum](string(data[1 : n-1]))) if !ok { - return fmt.Errorf("enum %s: unknown string %s", TrueNameOf[Enum](), str) + return fmt.Errorf("enum %s: unknown string %s", TrueNameOf[Enum](), string(data[1:n-1])) } *t = enum @@ -272,11 +272,12 @@ func UnmarshalJSON[Enum any](data []byte, t *Enum) error { // ValueSQL serializes an enum into a database-compatible format. func ValueSQL[Enum any](value Enum) (driver.Value, error) { - if !IsValid(value) { + str, ok := mtmap.Get2(mtkey.Enum2String(value)) + if !ok { return nil, fmt.Errorf("enum %s: invalid value %#v", TrueNameOf[Enum](), value) } - return ToString(value), nil + return str, nil } // ScanSQL deserializes a database value into an enum type. @@ -288,16 +289,15 @@ func ScanSQL[Enum any](a any, value *Enum) error { case []byte: data = string(t) default: - return fmt.Errorf("not support type %T", a) + return fmt.Errorf("enum %s: not support type %s", TrueNameOf[Enum](), reflect.TypeOf(a)) } - enum, ok := FromString[Enum](data) + enum, ok := mtmap.Get2(mtkey.String2Enum[Enum](data)) if !ok { return fmt.Errorf("enum %s: unknown string %s", TrueNameOf[Enum](), data) } *value = enum - return nil } diff --git a/internal/core/core.go b/internal/core/core.go index 800313c..184d949 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -2,6 +2,7 @@ package core import ( "math" + "strconv" "github.com/xybor-x/enum/internal/mtkey" "github.com/xybor-x/enum/internal/mtmap" @@ -39,6 +40,7 @@ func MapAny[N xreflect.Number, Enum any](id N, enum Enum, s string) Enum { panic("duplicate enum is not allowed") } + mtmap.Set(mtkey.EnumToJSON(enum), strconv.Quote(s)) mtmap.Set(mtkey.Enum2String(enum), s) mtmap.Set(mtkey.String2Enum[Enum](s), enum) mapEnumNumber(enum, id) diff --git a/internal/mtkey/mtkey.go b/internal/mtkey/mtkey.go index 9d09fac..ceb23b2 100644 --- a/internal/mtkey/mtkey.go +++ b/internal/mtkey/mtkey.go @@ -65,3 +65,11 @@ func (trueNameOf[T]) InferValue() string { panic("not implemented") } func TrueNameOf[T any]() trueNameOf[T] { return trueNameOf[T]{} } + +type enumToJSON[T any] struct{ key T } + +func (enumToJSON[T]) InferValue() string { panic("not implemented") } + +func EnumToJSON[T any](key T) enumToJSON[T] { + return enumToJSON[T]{key: key} +} diff --git a/testing/enum_test.go b/testing/enum_test.go index 5c972e6..f3ce7c3 100644 --- a/testing/enum_test.go +++ b/testing/enum_test.go @@ -263,7 +263,7 @@ func TestEnumUnmarshalJSON(t *testing.T) { // Invalid data err = enum.UnmarshalJSON([]byte(`user"`), &data) - assert.ErrorContains(t, err, "invalid character") + assert.ErrorContains(t, err, "invalid string") // Invalid enum err = enum.UnmarshalJSON([]byte(`"admin"`), &data)