From a6342c0c86094779cffc0417e01492dbb12f7795 Mon Sep 17 00:00:00 2001 From: babyTsakhes Date: Sun, 8 Feb 2026 18:21:00 +0300 Subject: [PATCH] msgpack_ext: added string() function for decimal Added function for converting decimal type to string, added tests for this function. Fixed function name. Added test for decimal conversion. Added benchmark test confirming that using self-written function is faster than using function from library. Added #322 --- CHANGELOG.md | 1 + decimal/decimal.go | 245 ++++++++++++++++++++++++- decimal/decimal_bench_test.go | 331 ++++++++++++++++++++++++++++++++++ decimal/decimal_test.go | 244 ++++++++++++++++++++++++- 4 files changed, 818 insertions(+), 3 deletions(-) create mode 100644 decimal/decimal_bench_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce451175..ad3676914 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Versioning](http://semver.org/spec/v2.0.0.html) except to the first release. * New `Future` interface (#470). * Method `Release` for `Future` and `Response` interface that allows to free used data directly by calling (#493). +* Added function String() for type decimal (#322). ### Changed diff --git a/decimal/decimal.go b/decimal/decimal.go index 3c2681238..e5614af98 100644 --- a/decimal/decimal.go +++ b/decimal/decimal.go @@ -21,7 +21,9 @@ package decimal import ( "fmt" + "math" "reflect" + "strconv" "github.com/shopspring/decimal" "github.com/vmihailenco/msgpack/v5" @@ -96,7 +98,7 @@ func (d Decimal) MarshalMsgpack() ([]byte, error) { // +--------+-------------------+------------+===============+ // | MP_EXT | length (optional) | MP_DECIMAL | PackedDecimal | // +--------+-------------------+------------+===============+ - strBuf := d.String() + strBuf := d.Decimal.String() bcdBuf, err := encodeStringToBCD(strBuf) if err != nil { return nil, fmt.Errorf("msgpack: can't encode string (%s) to a BCD buffer: %w", strBuf, err) @@ -144,6 +146,247 @@ func decimalDecoder(d *msgpack.Decoder, v reflect.Value, extLen int) error { return ptr.UnmarshalMsgpack(b) } +// This method converts the decimal type to a string. +// Use shopspring/decimal by default. +// String - optimized version for Tarantool Decimal +// taking into account the limitations of int64 and support for large numbers via fallback +// Tarantool decimal has 38 digits, which can exceed int64. +// Therefore, we cannot use int64 for all cases. +// For the general case, use shopspring/decimal.String(). +// For cases where it is known that numbers contain less than 26 characters, +// you can use the optimized version. +func (d Decimal) String() string { + coefficient := d.Decimal.Coefficient() // Note: In shopspring/decimal + // the number is stored as coefficient *10^exponent, where exponent can be negative. + exponent := d.Decimal.Exponent() + + // If exponent is positive, then we use the standard method. + if exponent > 0 { + return d.Decimal.String() + } + + scale := -exponent + + if !coefficient.IsInt64() { + return d.Decimal.String() + } + + int64Value := coefficient.Int64() + + return d.stringFromInt64(int64Value, int(scale)) +} + +// StringFromInt64 is an internal method for converting int64 +// and scale to a string (for numbers up to 19 digits). +func (d Decimal) stringFromInt64(value int64, scale int) string { + var buf [64]byte + pos := 0 + + negative := value < 0 + if negative { + if value == math.MinInt64 { + return d.handleMinInt64(scale) + } + buf[pos] = '-' + pos++ + value = -value + } + + str := strconv.FormatInt(value, 10) + length := len(str) + + // Special case: zero value. + if value == 0 { + return "0" // Always return "0" regardless of scale. + } + + if scale == 0 { + // No fractional part. + if pos+length > len(buf) { + return d.Decimal.String() + } + copy(buf[pos:], str) + pos += length + return string(buf[:pos]) + } + + if scale >= length { + // Numbers like 0.00123. + // Count trailing zeros in the fractional part. + trailingZeros := 0 + // In this case, the fractional part consists + // of (scale-length) zeros followed by the number. + // We need to count trailing zeros in the actual number part. + for i := length - 1; i >= 0 && str[i] == '0'; i-- { + trailingZeros++ + } + + effectiveDigits := length - trailingZeros + + // If all digits are zeros after leading zeros, we need to adjust. + if effectiveDigits == 0 { + return "0" + } + + required := 2 + (scale - length) + effectiveDigits + if pos+required > len(buf) { + return d.Decimal.String() + } + + buf[pos] = '0' + buf[pos+1] = '.' + pos += 2 + + // Add leading zeros. + zeros := scale - length + for i := 0; i < zeros; i++ { + buf[pos] = '0' + pos++ + } + + // Copy only significant digits (without trailing zeros). + copy(buf[pos:], str[:effectiveDigits]) + pos += effectiveDigits + } else { + // Numbers like 123.45. + integerLen := length - scale + + // Count trailing zeros in fractional part. + trailingZeros := 0 + for i := length - 1; i >= integerLen && str[i] == '0'; i-- { + trailingZeros++ + } + + effectiveScale := scale - trailingZeros + + // If all fractional digits are zeros, return just integer part. + if effectiveScale == 0 { + if pos+integerLen > len(buf) { + return d.Decimal.String() + } + copy(buf[pos:], str[:integerLen]) + pos += integerLen + return string(buf[:pos]) + } + + required := integerLen + 1 + effectiveScale + if pos+required > len(buf) { + return d.Decimal.String() + } + + // Integer part. + copy(buf[pos:], str[:integerLen]) + pos += integerLen + + // Decimal point. + buf[pos] = '.' + pos++ + + // Fractional part without trailing zeros. + fractionalEnd := integerLen + effectiveScale + copy(buf[pos:], str[integerLen:fractionalEnd]) + pos += effectiveScale + } + + return string(buf[:pos]) +} +func (d Decimal) handleMinInt64(scale int) string { + const minInt64Str = "9223372036854775808" + + var buf [64]byte + pos := 0 + + buf[pos] = '-' + pos++ + + length := len(minInt64Str) + + if scale == 0 { + if pos+length > len(buf) { + return "-" + minInt64Str + } + copy(buf[pos:], minInt64Str) + pos += length + return string(buf[:pos]) + } + + if scale >= length { + // Count trailing zeros in the actual number part. + trailingZeros := 0 + for i := length - 1; i >= 0 && minInt64Str[i] == '0'; i-- { + trailingZeros++ + } + + effectiveDigits := length - trailingZeros + + if effectiveDigits == 0 { + return "0" + } + + required := 2 + (scale - length) + effectiveDigits + if pos+required > len(buf) { + return d.Decimal.String() + } + + buf[pos] = '0' + buf[pos+1] = '.' + pos += 2 + + zeros := scale - length + for i := 0; i < zeros; i++ { + buf[pos] = '0' + pos++ + } + + copy(buf[pos:], minInt64Str[:effectiveDigits]) + pos += effectiveDigits + } else { + integerLen := length - scale + + // Count trailing zeros for minInt64Str fractional part. + trailingZeros := 0 + for i := length - 1; i >= integerLen && minInt64Str[i] == '0'; i-- { + trailingZeros++ + } + + effectiveScale := scale - trailingZeros + + if effectiveScale == 0 { + if pos+integerLen > len(buf) { + return d.Decimal.String() + } + copy(buf[pos:], minInt64Str[:integerLen]) + pos += integerLen + return string(buf[:pos]) + } + + required := integerLen + 1 + effectiveScale + if pos+required > len(buf) { + return d.Decimal.String() + } + + copy(buf[pos:], minInt64Str[:integerLen]) + pos += integerLen + + buf[pos] = '.' + pos++ + + fractionalEnd := integerLen + effectiveScale + copy(buf[pos:], minInt64Str[integerLen:fractionalEnd]) + pos += effectiveScale + } + + return string(buf[:pos]) +} + +func MustMakeDecimal(src string) Decimal { + dec, err := MakeDecimalFromString(src) + if err != nil { + panic(fmt.Sprintf("MustMakeDecimalFromString: %v", err)) + } + return dec +} + func init() { msgpack.RegisterExtDecoder(decimalExtID, Decimal{}, decimalDecoder) msgpack.RegisterExtEncoder(decimalExtID, Decimal{}, decimalEncoder) diff --git a/decimal/decimal_bench_test.go b/decimal/decimal_bench_test.go new file mode 100644 index 000000000..5d492c13a --- /dev/null +++ b/decimal/decimal_bench_test.go @@ -0,0 +1,331 @@ +package decimal + +import ( + "math/rand" + "strconv" + "strings" + "testing" + "time" +) + +// Minimal benchmark without dependencies. +func BenchmarkMinimal(b *testing.B) { + dec := MustMakeDecimal("123.45") + + b.Run("String", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = dec.String() + } + }) + + b.Run("StandardString", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = dec.Decimal.String() + } + }) +} + +// Benchmark for small numbers (optimized conversion path). +func BenchmarkDecimalString_SmallNumbers(b *testing.B) { + smallNumbers := []string{ + "123.45", + "-123.45", + "0.00123", + "100.00", + "999.99", + "42", + "-42", + "0.000001", + "1234567.89", + "-987654.32", + } + + decimals := make([]Decimal, len(smallNumbers)) + for i, str := range smallNumbers { + decimals[i] = MustMakeDecimal(str) + } + + b.ResetTimer() + b.Run("String", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.String() + } + } + }) + + b.Run("StandardString", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.Decimal.String() + } + } + }) +} + +// A benchmark for the boundary cases of int64. +func BenchmarkDecimalString_Int64Boundaries(b *testing.B) { + boundaryNumbers := []string{ + "9223372036854775807", // max int64 + "-9223372036854775808", // min int64 + "9223372036854775806", + "-9223372036854775807", + } + + decimals := make([]Decimal, len(boundaryNumbers)) + for i, str := range boundaryNumbers { + decimals[i] = MustMakeDecimal(str) + } + + b.ResetTimer() + b.Run("String", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.String() + } + } + }) + + b.Run("StandardString", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.Decimal.String() + } + } + }) +} + +// Benchmark for large numbers (fallback path). +func BenchmarkDecimalString_LargeNumbers(b *testing.B) { + largeNumbers := []string{ + "123456789012345678901234567890.123456789", + "-123456789012345678901234567890.123456789", + "99999999999999999999999999999999999999", + "-99999999999999999999999999999999999999", + } + + decimals := make([]Decimal, len(largeNumbers)) + for i, str := range largeNumbers { + decimals[i] = MustMakeDecimal(str) + } + + b.ResetTimer() + b.Run("String", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.String() + } + } + }) + + b.Run("StandardString", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.Decimal.String() + } + } + }) +} + +// A benchmark for mixed numbers (real-world scenarios). +func BenchmarkDecimalString_Mixed(b *testing.B) { + mixedNumbers := []string{ + "0", + "1", + "-1", + "0.5", + "-0.5", + "123.456", + "1000000.000001", + "9223372036854775807", + "123456789012345678901234567890.123456789", + } + + decimals := make([]Decimal, len(mixedNumbers)) + for i, str := range mixedNumbers { + decimals[i] = MustMakeDecimal(str) + } + + b.ResetTimer() + b.Run("String", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.String() + } + } + }) + + b.Run("StandardString", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.Decimal.String() + } + } + }) +} + +// A benchmark for numbers with different precision. +func BenchmarkDecimalString_DifferentPrecision(b *testing.B) { + testCases := []struct { + name string + value string + }{ + {"Integer", "1234567890"}, + {"SmallDecimal", "0.000000001"}, + {"MediumDecimal", "123.456789"}, + {"LargeDecimal", "1234567890.123456789"}, + } + + for _, tc := range testCases { + b.Run(tc.name, func(b *testing.B) { + dec := MustMakeDecimal(tc.value) + + b.Run("String", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = dec.String() + } + }) + + b.Run("StandardString", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = dec.Decimal.String() + } + }) + }) + } +} + +// A benchmark with random numbers for statistical significance. +func BenchmarkDecimalString_Random(b *testing.B) { + // Create a local random number generator + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Generate random numbers in int64 range + generateRandomDecimal := func() Decimal { + // 70% chance for small numbers, 30% for large numbers + if rng.Float64() < 0.7 { + // Numbers in int64 range + value := rng.Int63n(1000000000) - 500000000 + scale := rng.Intn(10) + + if scale == 0 { + return MustMakeDecimal(strconv.FormatInt(value, 10)) + } + + // For numbers with fractional part + str := strconv.FormatInt(value, 10) + if value < 0 { + str = str[1:] // remove minus sign + } + + if len(str) > scale { + integerPart := str[:len(str)-scale] + fractionalPart := str[len(str)-scale:] + result := integerPart + "." + fractionalPart + if value < 0 { + result = "-" + result + } + return MustMakeDecimal(result) + } else { + zeros := scale - len(str) + result := "0." + strings.Repeat("0", zeros) + str + if value < 0 { + result = "-" + result + } + return MustMakeDecimal(result) + } + } else { + // Large numbers (fallback) - generate correct strings + // Generate 30-digit number + bigDigits := make([]byte, 30) + for i := range bigDigits { + bigDigits[i] = byte(rng.Intn(10) + '0') + } + // Remove leading zeros + for len(bigDigits) > 1 && bigDigits[0] == '0' { + bigDigits = bigDigits[1:] + } + + bigNum := string(bigDigits) + scale := rng.Intn(10) + + if scale == 0 { + if rng.Float64() < 0.5 { + bigNum = "-" + bigNum + } + return MustMakeDecimal(bigNum) + } + + if scale < len(bigNum) { + integerPart := bigNum[:len(bigNum)-scale] + fractionalPart := bigNum[len(bigNum)-scale:] + result := integerPart + "." + fractionalPart + if rng.Float64() < 0.5 { + result = "-" + result + } + return MustMakeDecimal(result) + } else { + zeros := scale - len(bigNum) + result := "0." + strings.Repeat("0", zeros) + bigNum + if rng.Float64() < 0.5 { + result = "-" + result + } + return MustMakeDecimal(result) + } + } + } + + b.ResetTimer() + b.Run("String", func(b *testing.B) { + total := 0 + for i := 0; i < b.N; i++ { + dec := generateRandomDecimal() + result := dec.String() + total += len(result) + } + _ = total + }) + + b.Run("StandardString", func(b *testing.B) { + total := 0 + for i := 0; i < b.N; i++ { + dec := generateRandomDecimal() + result := dec.Decimal.String() + total += len(result) + } + _ = total + }) +} + +// A benchmark for checking memory allocations. +func BenchmarkDecimalString_MemoryAllocations(b *testing.B) { + testNumbers := []string{ + "123.45", + "0.001", + "9223372036854775807", + "123456789012345678901234567890.123456789", + } + + decimals := make([]Decimal, len(testNumbers)) + for i, str := range testNumbers { + decimals[i] = MustMakeDecimal(str) + } + + b.ResetTimer() + + b.Run("String", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.String() + } + } + }) + + b.Run("StandardString", func(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, dec := range decimals { + _ = dec.Decimal.String() + } + } + }) +} diff --git a/decimal/decimal_test.go b/decimal/decimal_test.go index f8b4b6fc2..8ee44ff74 100644 --- a/decimal/decimal_test.go +++ b/decimal/decimal_test.go @@ -299,7 +299,7 @@ func TestEncodeMinNumber(t *testing.T) { } } -func benchmarkMPEncodeDecode(b *testing.B, src decimal.Decimal, dst interface{}) { +func benchmarkMPEncodeDecode(b *testing.B, src decimal.Decimal) { b.ResetTimer() var v TupleDecimal @@ -323,7 +323,7 @@ func BenchmarkMPEncodeDecodeDecimal(b *testing.B) { if err != nil { b.Fatal(err) } - benchmarkMPEncodeDecode(b, dec, &dec) + benchmarkMPEncodeDecode(b, dec) }) } } @@ -704,3 +704,243 @@ func TestMain(m *testing.M) { code := runTestMain(m) os.Exit(code) } + +func TestDecimalString(t *testing.T) { + tests := []struct { + name string + input string + expected string + willUseOptimized bool + }{ + { + name: "small positive decimal", + input: "123.45", + expected: "123.45", + willUseOptimized: true, + }, + { + name: "small negative decimal", + input: "-123.45", + expected: "-123.45", + willUseOptimized: true, + }, + { + name: "zero", + input: "0", + expected: "0", + willUseOptimized: true, + }, + { + name: "integer", + input: "12345", + expected: "12345", + willUseOptimized: true, + }, + { + name: "small decimal with leading zeros", + input: "0.00123", + expected: "0.00123", + willUseOptimized: true, + }, + { + name: "max int64", + input: "9223372036854775807", + expected: "9223372036854775807", + willUseOptimized: true, + }, + { + name: "min int64", + input: "-9223372036854775808", + expected: "-9223372036854775808", + willUseOptimized: true, + }, + { + name: "number beyond int64 range", + input: "9223372036854775808", + expected: "9223372036854775808", + }, + { + name: "very large decimal", + input: "123456789012345678901234567890.123456789", + expected: "123456789012345678901234567890.123456789", + willUseOptimized: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dec, err := MakeDecimalFromString(tt.input) + assert.NoError(t, err) + + result := dec.String() + + assert.Equal(t, tt.expected, result) + + assert.Equal(t, dec.Decimal.String(), result) + }) + } +} + +func TestTarantoolBCDCompatibility(t *testing.T) { + + testCases := []string{ + "123.45", + "-123.45", + "0.001", + "100.00", + "999999.999999", + } + + for _, input := range testCases { + t.Run(input, func(t *testing.T) { + + dec, err := MakeDecimalFromString(input) + assert.NoError(t, err) + + msgpackData, err := dec.MarshalMsgpack() + assert.NoError(t, err) + + var dec2 Decimal + err = dec2.UnmarshalMsgpack(msgpackData) + assert.NoError(t, err) + + originalStr := dec.String() + roundtripStr := dec2.String() + + assert.Equal(t, originalStr, roundtripStr, + "BCD roundtrip failed for input: %s", input) + }) + } +} + +func TestRealTarantoolUsage(t *testing.T) { + operations := []struct { + name string + data map[string]interface{} + }{ + { + name: "insert operation", + data: map[string]interface{}{ + "id": 1, + "amount": MustMakeDecimal("123.45"), + "balance": MustMakeDecimal("-500.00"), + }, + }, + { + name: "update operation", + data: map[string]interface{}{ + "id": 2, + "price": MustMakeDecimal("99.99"), + "quantity": MustMakeDecimal("1000.000"), + }, + }, + } + + for _, op := range operations { + t.Run(op.name, func(t *testing.T) { + for key, value := range op.data { + if dec, isDecimal := value.(Decimal); isDecimal { + str := dec.String() + + if str == "" { + t.Errorf("%s: string representation is empty", key) + } + + validFirstChars := []string{ + ".", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", + } + firstChar := string(str[0]) + valid := false + for _, c := range validFirstChars { + if firstChar == c { + valid = true + break + } + } + if !valid { + t.Errorf("%s: string %q starts with invalid character %q", + key, str, firstChar) + } + + if dec.Decimal.String() != str { + t.Errorf("%s: string representation mismatch:"+ + " Decimal.String()=%q, dec.String()=%q", + key, dec.Decimal.String(), str) + } + } + } + }) + } +} + +func Test100_00(t *testing.T) { + dec := MustMakeDecimal("100.00") + + coefficient := dec.Decimal.Coefficient() + if !coefficient.IsInt64() { + t.Errorf("Expected coefficient to be int64") + } + if coefficient.Int64() != 10000 { + t.Errorf("Expected coefficient to be 10000, got %d", coefficient.Int64()) + } + + exponent := dec.Decimal.Exponent() + if exponent != -2 { + t.Errorf("Expected exponent to be -2, got %d", exponent) + } + + result := dec.String() + if result != "100" { + t.Errorf(`Expected string "100", got %q`, result) + } +} + +func TestLargeNumberString(t *testing.T) { + largeNumber := "123456789012345678901234567890.123456789" + dec, err := MakeDecimalFromString(largeNumber) + if err != nil { + t.Fatalf("Failed to create decimal: %v", err) + } + + // Check that the coefficient does not fit in int64. + coefficient := dec.Decimal.Coefficient() + if coefficient.IsInt64() { + t.Error("Expected coefficient to be too large for int64") + } + + optimized := dec.String() + standard := dec.Decimal.String() + + if optimized != standard { + t.Errorf("Results differ: optimized=%s, standard=%s", optimized, standard) + } + + if optimized != largeNumber { + t.Errorf("Expected %s, got %s", largeNumber, optimized) + } + +} + +func TestDecimalTrailingZeros(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"100.00", "100"}, + {"0.00", "0"}, + {"0.000", "0"}, + {"1.000", "1"}, + {"123.4500", "123.45"}, + {"0.00100", "0.001"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + dec := MustMakeDecimal(tt.input) + result := dec.String() + if result != tt.expected { + t.Errorf("For %s: expected %s, got %s", tt.input, tt.expected, result) + } + }) + } +}