From ac5667545c800dfa0c16734e6c81fbeae8ee3a66 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Thu, 14 Nov 2024 15:39:18 +0100 Subject: [PATCH 1/3] Implement custom hybrid un-/marshal model --- math/dec.go | 83 ++++++++++++++++++++++++++++-- math/dec_test.go | 128 +++++++++++++++++++++++++++++++---------------- 2 files changed, 164 insertions(+), 47 deletions(-) diff --git a/math/dec.go b/math/dec.go index 3f96345e249b..0fd520ec58d2 100644 --- a/math/dec.go +++ b/math/dec.go @@ -4,6 +4,7 @@ import ( "encoding/json" stderrors "errors" "math/big" + "strconv" "github.com/cockroachdb/apd/v3" @@ -407,14 +408,88 @@ func (x Dec) Reduce() (Dec, int) { return y, n } +// Marshal serializes the decimal value into a byte slice in text format. +// This method represents the decimal in a portable and compact hybrid notation. +// Based on the exponent value, the number is formatted into decimal: -ddddd.ddddd, no exponent +// or scientific notation: -d.ddddE±dd +// +// For example, the following transformations are made: +// - 0 -> 0 +// - 123 -> 123 +// - 10000 -> 10000 +// - -0.001 -> -0.001 +// - -0.000000001 -> -1E-9 +// +// Returns: +// - A byte slice of the decimal in text format. +// - An error if the decimal cannot be reduced or marshaled properly. func (x Dec) Marshal() ([]byte, error) { - // implemented in a new PR. See: https://github.com/cosmos/cosmos-sdk/issues/22525 - panic("not implemented") + var d apd.Decimal + if _, _, err := dec128Context.Reduce(&d, &x.dec); err != nil { + return nil, ErrInvalidDec.Wrap(err.Error()) + } + return fmtE(d, 'E'), nil +} + +// custom formatter +func fmtE(d apd.Decimal, fmt byte) []byte { + var scratch, dest [16]byte + buf := dest[:0] + digits := d.Coeff.Append(scratch[:0], 10) + totalDigits := int64(len(digits)) + adj := int64(d.Exponent) + totalDigits - 1 + if adj > -6 && adj < 6 { + return []byte(d.Text('f')) + } + switch { + case totalDigits > 5: + beforeComma := digits[0 : totalDigits-6] + adj -= int64(len(beforeComma) - 1) + buf = append(buf, beforeComma...) + buf = append(buf, '.') + buf = append(buf, digits[totalDigits-6:]...) + case totalDigits > 1: + buf = append(buf, digits[0]) + buf = append(buf, '.') + buf = append(buf, digits[1:]...) + default: + buf = append(buf, digits[0:]...) + } + + buf = append(buf, fmt) + var ch byte + if adj < 0 { + ch = '-' + adj = -adj + } else { + ch = '+' + } + buf = append(buf, ch) + return strconv.AppendInt(buf, adj, 10) } +// isEmptyExp checks if the adjusted exponent of the given decimal is zero. +func isEmptyExp(d apd.Decimal) bool { + var scratch [16]byte + digits := d.Coeff.Append(scratch[:0], 10) + adj := int64(d.Exponent) + int64(len(digits)) - 1 + return adj == 0 +} + +// Unmarshal parses a byte slice containing a text-formatted decimal and stores the result in the receiver. +// It returns an error if the byte slice does not represent a valid decimal. func (x *Dec) Unmarshal(data []byte) error { - // implemented in a new PR. See: https://github.com/cosmos/cosmos-sdk/issues/22525 - panic("not implemented") + result, err := NewDecFromString(string(data)) + if err != nil { + return ErrInvalidDec.Wrap(err.Error()) + } + + if result.dec.Form != apd.Finite { + return ErrInvalidDec.Wrap("unknown decimal form") + } + + x.dec = result.dec + return nil } // MarshalTo encodes the receiver into the provided byte slice and returns the number of bytes written and any error encountered. diff --git a/math/dec_test.go b/math/dec_test.go index ef4c9fbd5de3..6e9eeafeda75 100644 --- a/math/dec_test.go +++ b/math/dec_test.go @@ -1309,55 +1309,38 @@ func must[T any](r T, err error) T { } func TestMarshalUnmarshal(t *testing.T) { - t.Skip("not supported, yet") specs := map[string]struct { x Dec exp string expErr error }{ - "No trailing zeros": { - x: NewDecFromInt64(123456), - exp: "1.23456E+5", - }, - "Trailing zeros": { - x: NewDecFromInt64(123456000), - exp: "1.23456E+8", - }, "Zero value": { x: NewDecFromInt64(0), - exp: "0E+0", + exp: "0", }, "-0": { x: NewDecFromInt64(-0), - exp: "0E+0", - }, - "Decimal value": { - x: must(NewDecFromString("1.30000")), - exp: "1.3E+0", - }, - "Positive value": { - x: NewDecFromInt64(10), - exp: "1E+1", + exp: "0", }, - "negative 10": { - x: NewDecFromInt64(-10), - exp: "-1E+1", + "1 decimal place": { + x: must(NewDecFromString("0.1")), + exp: "0.1", }, - "9 with trailing zeros": { - x: must(NewDecFromString("9." + strings.Repeat("0", 34))), - exp: "9E+0", + "2 decimal places": { + x: must(NewDecFromString("0.01")), + exp: "0.01", }, - "negative 1 with negative exponent zeros": { - x: must(NewDecFromString("-1.000001")), - exp: "-1.000001E+0", + "3 decimal places": { + x: must(NewDecFromString("0.001")), + exp: "0.001", }, - "negative 1 with trailing zeros": { - x: must(NewDecFromString("-1." + strings.Repeat("0", 34))), - exp: "-1E+0", + "4 decimal places": { + x: must(NewDecFromString("0.0001")), + exp: "0.0001", }, "5 decimal places": { x: must(NewDecFromString("0.00001")), - exp: "1E-5", + exp: "0.00001", }, "6 decimal places": { x: must(NewDecFromString("0.000001")), @@ -1367,17 +1350,73 @@ func TestMarshalUnmarshal(t *testing.T) { x: must(NewDecFromString("0.0000001")), exp: "1E-7", }, + "1": { + x: must(NewDecFromString("1")), + exp: "1", + }, + "12": { + x: must(NewDecFromString("12")), + exp: "12", + }, + "123": { + x: must(NewDecFromString("123")), + exp: "123", + }, + "1234": { + x: must(NewDecFromString("1234")), + exp: "1234", + }, + "12345": { + x: must(NewDecFromString("12345")), + exp: "12345", + }, + "123456": { + x: must(NewDecFromString("123456")), + exp: "123456", + }, + "1234567": { + x: must(NewDecFromString("1234567")), + exp: "1.234567E+6", + }, + "12345678": { + x: must(NewDecFromString("12345678")), + exp: "12.345678E+6", + }, + "123456789": { + x: must(NewDecFromString("123456789")), + exp: "123.456789E+6", + }, + "1234567890": { + x: must(NewDecFromString("1234567890")), + exp: "123.456789E+7", + }, + "12345678900": { + x: must(NewDecFromString("12345678900")), + exp: "123.456789E+8", + }, + "negative 1 with negative exponent": { + x: must(NewDecFromString("-1.000001")), + exp: "-1.000001", + }, + "-1.0000001 - negative 1 with negative exponent": { + x: must(NewDecFromString("-1.0000001")), + exp: "-1.0000001", + }, + "3 decimal places before the comma": { + x: must(NewDecFromString("100")), + exp: "100", + }, "4 decimal places before the comma": { x: must(NewDecFromString("1000")), - exp: "1E+3", + exp: "1000", }, "5 decimal places before the comma": { x: must(NewDecFromString("10000")), - exp: "1E+4", + exp: "10000", }, "6 decimal places before the comma": { x: must(NewDecFromString("100000")), - exp: "1E+5", + exp: "100000", }, "7 decimal places before the comma": { x: must(NewDecFromString("1000000")), @@ -1388,12 +1427,12 @@ func TestMarshalUnmarshal(t *testing.T) { exp: "1E+100000", }, "1.1e100000": { - x: NewDecWithExp(11, 100_000), - expErr: ErrInvalidDec, + x: must(NewDecFromString("1.1e100000")), + exp: "1.1E+100000", }, - "1.e100000": { - x: NewDecWithExp(1, 100_000), - exp: "1E+100000", + "1e100001": { + x: NewDecWithExp(1, 100_001), + expErr: ErrInvalidDec, }, } for name, spec := range specs { @@ -1404,9 +1443,12 @@ func TestMarshalUnmarshal(t *testing.T) { return } require.NoError(t, gotErr) - unmarshalled := new(Dec) - require.NoError(t, unmarshalled.Unmarshal(marshaled)) - assert.Equal(t, spec.exp, unmarshalled.dec.Text('E')) + assert.Equal(t, spec.exp, string(marshaled)) + // and backwards + unmarshalledDec := new(Dec) + require.NoError(t, unmarshalledDec.Unmarshal(marshaled)) + assert.Equal(t, spec.x.String(), unmarshalledDec.String()) + assert.True(t, spec.x.Equal(*unmarshalledDec)) }) } } From a0a7e30aa93b8cf4d60687ae5c624dae04c10344 Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Mon, 25 Nov 2024 13:54:25 +0100 Subject: [PATCH 2/3] Changelog --- math/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/math/CHANGELOG.md b/math/CHANGELOG.md index 7ccd0e252390..1e9d08c3ada6 100644 --- a/math/CHANGELOG.md +++ b/math/CHANGELOG.md @@ -36,6 +36,9 @@ Ref: https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.j ## [Unreleased] +* [#11783](https://github.com/cosmos/cosmos-sdk/issues/11783) feat(math): Upstream GDA based decimal type + + ## [math/v1.4.0](https://github.com/cosmos/cosmos-sdk/releases/tag/math/v1.4.0) - 2024-01-20 ### Features From c951c71334bc08d596697a089395e984f7b236bb Mon Sep 17 00:00:00 2001 From: Alex Peters Date: Mon, 25 Nov 2024 14:31:47 +0100 Subject: [PATCH 3/3] Use hybrid format in string and json --- math/dec.go | 17 +++++------------ math/dec_examples_test.go | 2 +- math/dec_test.go | 27 ++++++++++++++------------- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/math/dec.go b/math/dec.go index 0fd520ec58d2..4935fb2eb9a3 100644 --- a/math/dec.go +++ b/math/dec.go @@ -300,7 +300,7 @@ func (x Dec) Int64() (int64, error) { // fit precisely into an *big.Int. func (x Dec) BigInt() (*big.Int, error) { y, _ := x.Reduce() - z, ok := new(big.Int).SetString(y.String(), 10) + z, ok := new(big.Int).SetString(y.Text('f'), 10) if !ok { return nil, ErrNonIntegral } @@ -335,7 +335,7 @@ func (x Dec) SdkIntTrim() (Int, error) { // String formatted in decimal notation: '-ddddd.dddd', no exponent func (x Dec) String() string { - return x.dec.Text('f') + return string(fmtE(x.dec, 'E')) } // Text converts the floating-point number x to a string according @@ -431,7 +431,8 @@ func (x Dec) Marshal() ([]byte, error) { return fmtE(d, 'E'), nil } -// custom formatter +// fmtE formats a decimal number into a byte slice in scientific notation or fixed-point notation depending on the exponent. +// If the adjusted exponent is between -6 and 6 inclusive, it uses fixed-point notation, otherwise it uses scientific notation. func fmtE(d apd.Decimal, fmt byte) []byte { var scratch, dest [16]byte buf := dest[:0] @@ -468,14 +469,6 @@ func fmtE(d apd.Decimal, fmt byte) []byte { return strconv.AppendInt(buf, adj, 10) } -// isEmptyExp checks if the adjusted exponent of the given decimal is zero. -func isEmptyExp(d apd.Decimal) bool { - var scratch [16]byte - digits := d.Coeff.Append(scratch[:0], 10) - adj := int64(d.Exponent) + int64(len(digits)) - 1 - return adj == 0 -} - // Unmarshal parses a byte slice containing a text-formatted decimal and stores the result in the receiver. // It returns an error if the byte slice does not represent a valid decimal. func (x *Dec) Unmarshal(data []byte) error { @@ -510,7 +503,7 @@ func (x Dec) Size() int { // MarshalJSON serializes the Dec struct into a JSON-encoded byte slice using scientific notation. func (x Dec) MarshalJSON() ([]byte, error) { - return json.Marshal(x.dec.Text('E')) + return json.Marshal(fmtE(x.dec, 'E')) } // UnmarshalJSON implements the json.Unmarshaler interface for the Dec type, converting JSON strings to Dec objects. diff --git a/math/dec_examples_test.go b/math/dec_examples_test.go index 5c2dc8a021ed..c2cf3f5a836f 100644 --- a/math/dec_examples_test.go +++ b/math/dec_examples_test.go @@ -308,7 +308,7 @@ func ExampleDec_MulExact() { // 2.50 // exponent out of range: invalid decimal // unexpected rounding - // 0.00000000000000000000000000000000000 + // 0E-35 // 0 } diff --git a/math/dec_test.go b/math/dec_test.go index 6e9eeafeda75..f5e91572c0d2 100644 --- a/math/dec_test.go +++ b/math/dec_test.go @@ -3,7 +3,6 @@ package math import ( "fmt" "math" - "strconv" "strings" "testing" @@ -152,11 +151,11 @@ func TestNewDecFromInt64(t *testing.T) { }, "max value": { src: math.MaxInt64, - exp: strconv.Itoa(math.MaxInt64), + exp: "9223372036854.775807E+6", }, "min value": { src: math.MinInt64, - exp: strconv.Itoa(math.MinInt64), + exp: "9223372036854.775808E+6", }, } for name, spec := range specs { @@ -1247,15 +1246,17 @@ func TestToBigInt(t *testing.T) { {"12345.6", "", ErrNonIntegral}, } for idx, tc := range tcs { - a, err := NewDecFromString(tc.intStr) - require.NoError(t, err) - b, err := a.BigInt() - if tc.isError == nil { - require.NoError(t, err, "test_%d", idx) - require.Equal(t, tc.out, b.String(), "test_%d", idx) - } else { - require.ErrorIs(t, err, tc.isError, "test_%d", idx) - } + t.Run(fmt.Sprintf("%d", idx), func(t *testing.T) { + a, err := NewDecFromString(tc.intStr) + require.NoError(t, err) + b, err := a.BigInt() + if tc.isError == nil { + require.NoError(t, err, "test_%d", idx) + require.Equal(t, tc.out, b.String(), "test_%d", idx) + } else { + require.ErrorIs(t, err, tc.isError, "test_%d", idx) + } + }) } } @@ -1447,7 +1448,7 @@ func TestMarshalUnmarshal(t *testing.T) { // and backwards unmarshalledDec := new(Dec) require.NoError(t, unmarshalledDec.Unmarshal(marshaled)) - assert.Equal(t, spec.x.String(), unmarshalledDec.String()) + assert.Equal(t, spec.exp, unmarshalledDec.String()) assert.True(t, spec.x.Equal(*unmarshalledDec)) }) }