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) + } + }) + } +}