Skip to content

Commit abb25a6

Browse files
committed
decimal: add support decimal type in msgpack
This patch provides decimal support for all space operations and as function return result. Decimal type was introduced in Tarantool 2.2. See more about decimal type in [1] and [2]. According to BCD encoding/decoding specification sign is encoded by letters: '0x0a', '0x0c', '0x0e', '0x0f' stands for plus, and '0x0b' and '0x0d' for minus. Tarantool always uses '0x0c' for plus and '0x0d' for minus. Implementation in Golang follows the same rule and in all test samples sign encoded by '0x0d' and '0x0c' for simplification. Because 'c' used by Tarantool. To use decimal with github.com/shopspring/decimal in msgpack, import tarantool/decimal submodule. 1. https://www.tarantool.io/en/doc/latest/book/box/data_model/ 2. https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-decimal-type 3. https://github.com/douglascrockford/DEC64/blob/663f562a5f0621021b98bfdd4693571993316174/dec64_test.c#L62-L104 4. https://github.com/shopspring/decimal/blob/v1.3.1/decimal_test.go#L27-L64 5. https://github.com/tarantool/tarantool/blob/60fe9d14c1c7896aa7d961e4b68649eddb4d2d6c/test/unit/decimal.c#L154-L171 Lua snippet for encoding number to MsgPack representation: local decimal = require('decimal') local function mp_encode_dec(num) local dec = msgpack.encode(decimal.new(num)) return dec:gsub('.', function (c) return string.format('%02x', string.byte(c)) end) end print(mp_encode_dec(-12.34)) -- 0xd6010201234d Follows up tarantool/tarantool#692 Part of #96
1 parent de95e31 commit abb25a6

File tree

9 files changed

+894
-0
lines changed

9 files changed

+894
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Versioning](http://semver.org/spec/v2.0.0.html) except to the first release.
1717
- Go modules support (#91)
1818
- queue-utube handling (#85)
1919
- Master discovery (#113)
20+
- Support decimal type in msgpack (#96)
2021

2122
### Fixed
2223

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ test-main:
4545
go clean -testcache
4646
go test . -v -p 1
4747

48+
.PHONY: test-decimal
49+
test-decimal:
50+
@echo "Running tests in decimal package"
51+
go clean -testcache
52+
go test ./decimal/ -v -p 1
53+
4854
.PHONY: coverage
4955
coverage:
5056
go clean -testcache

decimal/bcd.go

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Package decimal implements methods to encode and decode BCD.
2+
//
3+
// BCD (Binary-Coded Decimal) is a sequence of bytes representing decimal
4+
// digits of the encoded number (each byte has two decimal digits each encoded
5+
// using 4-bit nibbles), so byte >> 4 is the first digit and byte & 0x0f is the
6+
// second digit. The leftmost digit in the array is the most significant. The
7+
// rightmost digit in the array is the least significant.
8+
//
9+
// The first byte of the BCD array contains the first digit of the number,
10+
// represented as follows:
11+
//
12+
// | 4 bits | 4 bits |
13+
// = 0x = the 1st digit
14+
//
15+
// (The first nibble contains 0 if the decimal number has an even number of
16+
// digits). The last byte of the BCD array contains the last digit of the
17+
// number and the final nibble, represented as follows:
18+
//
19+
// | 4 bits | 4 bits |
20+
// = the last digit = nibble
21+
//
22+
// The final nibble represents the number's sign: 0x0a, 0x0c, 0x0e, 0x0f stand
23+
// for plus, 0x0b and 0x0d stand for minus.
24+
//
25+
// Examples:
26+
//
27+
// The decimal -12.34 will be encoded as 0xd6, 0x01, 0x02, 0x01, 0x23, 0x4d:
28+
//
29+
// |MP_EXT (fixext 4) | MP_DECIMAL | scale | 1 | 2,3 | 4 (minus) |
30+
// | 0xd6 | 0x01 | 0x02 | 0x01 | 0x23 | 0x4d |
31+
//
32+
// The decimal 0.000000000000000000000000000000000010 will be encoded as
33+
// 0xc7, 0x03, 0x01, 0x24, 0x01, 0x0c:
34+
//
35+
// | MP_EXT (ext 8) | length | MP_DECIMAL | scale | 1 | 0 (plus) |
36+
// | 0xc7 | 0x03 | 0x01 | 0x24 | 0x01 | 0x0c |
37+
//
38+
// See also:
39+
//
40+
// * MessagePack extensions https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/
41+
//
42+
// * An implementation in C language https://github.com/tarantool/decNumber/blob/master/decPacked.c
43+
package decimal
44+
45+
import (
46+
"fmt"
47+
"strconv"
48+
"strings"
49+
"unicode"
50+
)
51+
52+
var mapRuneToByteSign = map[rune]byte{
53+
'+': 0x0c,
54+
'-': 0x0d,
55+
}
56+
57+
var isNegative = map[byte]bool{
58+
0x0a: false,
59+
0x0b: true,
60+
0x0c: false,
61+
0x0d: true,
62+
0x0e: false,
63+
0x0f: false,
64+
}
65+
66+
// EncodeStringToBCD converts a string buffer to BCD Packed Decimal.
67+
//
68+
// The number is converted to a BCD packed decimal byte array, right aligned in
69+
// the BCD array, whose length is indicated by the second parameter. The final
70+
// 4-bit nibble in the array will be a sign nibble, 0x0a for "+" and 0x0b for
71+
// "-". Unused bytes and nibbles to the left of the number are set to 0. scale
72+
// is set to the scale of the number (this is the exponent, negated).
73+
func EncodeStringToBCD(buf string) ([]byte, error) {
74+
sign := '+' // By default number is positive.
75+
if res := strings.HasPrefix(buf, "-"); res {
76+
sign = '-'
77+
}
78+
79+
// Calculate a number of digits in the decimal number. Leading and
80+
// trailing zeros do not count.
81+
// Examples:
82+
// 0.0000000000000001 - 1 digit
83+
// 00012.34 - 4 digits
84+
// 0.340 - 2 digits
85+
s := strings.ReplaceAll(buf, "-", "") // Remove a sign.
86+
s = strings.ReplaceAll(s, "+", "") // Remove a sign.
87+
s = strings.ReplaceAll(s, ".", "") // Remove a dot.
88+
s = strings.TrimLeft(s, "0") // Remove leading zeros.
89+
c := len(s)
90+
91+
// Fix a case with a single 0.
92+
if c == 0 {
93+
s = "0" //nolint
94+
c = 1
95+
}
96+
97+
// The first nibble should contain 0, if the decimal number has an even
98+
// number of digits. Therefore highNibble is false when decimal number
99+
// is even.
100+
highNibble := true
101+
if c%2 == 0 {
102+
highNibble = false
103+
}
104+
scale := 0 // By default decimal number is integer.
105+
var byteBuf []byte
106+
for i, ch := range buf {
107+
// Skip leading zeros.
108+
if (len(byteBuf) == 0) && ch == '0' {
109+
continue
110+
}
111+
if (i == 0) && (ch == '-' || ch == '+') {
112+
continue
113+
}
114+
// Calculate a number of digits after the decimal point.
115+
if ch == '.' {
116+
scale = len(buf) - i - 1
117+
continue
118+
}
119+
if !unicode.IsDigit(ch) {
120+
return nil, fmt.Errorf("Symbol in position %d is not a digit: %c", i, ch)
121+
}
122+
123+
d, err := strconv.Atoi(string(ch))
124+
if err != nil {
125+
return nil, fmt.Errorf("Failed to convert symbol '%c' to a digit: %s", ch, err)
126+
}
127+
digit := byte(d)
128+
if highNibble {
129+
// Add a digit to a high nibble.
130+
digit = digit << 4
131+
byteBuf = append(byteBuf, digit)
132+
highNibble = false
133+
} else {
134+
if len(byteBuf) == 0 {
135+
byteBuf = make([]byte, 1)
136+
}
137+
// Add a digit to a low nibble.
138+
lowByteIdx := len(byteBuf) - 1
139+
byteBuf[lowByteIdx] = byteBuf[lowByteIdx] | digit
140+
highNibble = true
141+
}
142+
}
143+
if highNibble {
144+
// Put a sign to a high nibble.
145+
byteBuf = append(byteBuf, mapRuneToByteSign[sign])
146+
} else {
147+
// Put a sign to a low nibble.
148+
lowByteIdx := len(byteBuf) - 1
149+
byteBuf[lowByteIdx] = byteBuf[lowByteIdx] | mapRuneToByteSign[sign]
150+
}
151+
byteBuf = append([]byte{byte(scale)}, byteBuf...)
152+
153+
return byteBuf, nil
154+
}
155+
156+
// DecodeStringFromBCD converts a BCD Packed Decimal to a string buffer.
157+
//
158+
// The BCD packed decimal byte array, together with an associated scale, is
159+
// converted to a string. The BCD array is assumed full of digits, and must be
160+
// ended by a 4-bit sign nibble in the least significant four bits of the final
161+
// byte. The scale is used (negated) as the exponent of the decimal number.
162+
// Note that zeros may have a sign and/or a scale.
163+
func DecodeStringFromBCD(bcdBuf []byte) (string, error) {
164+
const scaleIdx = 0 // Index of a byte with scale.
165+
scale := int(bcdBuf[scaleIdx])
166+
// Get a BCD buffer without a byte with scale.
167+
bcdBuf = bcdBuf[scaleIdx+1:]
168+
length := len(bcdBuf)
169+
var digits []string
170+
for i, bcdByte := range bcdBuf {
171+
highNibble := int(bcdByte >> 4)
172+
if !(len(digits) == 0 && highNibble == 0) || len(bcdBuf) == 1 {
173+
digits = append(digits, strconv.Itoa(highNibble))
174+
}
175+
lowNibble := int(bcdByte & 0x0f)
176+
if !(len(digits) == 0 && lowNibble == 0) && i != length-1 {
177+
digits = append(digits, strconv.Itoa(int(lowNibble)))
178+
}
179+
}
180+
181+
// Add missing zeros when scale is less than current length.
182+
l := len(digits)
183+
if scale >= l {
184+
var zeros []string
185+
for i := 0; i <= scale-l; i++ {
186+
zeros = append(zeros, "0")
187+
digits = append(zeros, digits...)
188+
}
189+
}
190+
191+
// Add a dot when number is fractional.
192+
if scale != 0 {
193+
idx := len(digits) - scale
194+
digits = append(digits, "X") // [1 2 3 X]
195+
copy(digits[idx:], digits[idx-1:]) // [1 2 2 3]
196+
digits[idx] = "." // [1 . 2 3]
197+
}
198+
199+
// Add a sign, it is encoded in a low nibble of a last byte.
200+
lastByte := bcdBuf[length-1]
201+
sign := lastByte & 0x0f
202+
if isNegative[sign] {
203+
digits = append([]string{"-"}, digits...)
204+
}
205+
206+
// Merge slice to a single string.
207+
str := strings.Join(digits, "")
208+
209+
return str, nil
210+
}

decimal/config.lua

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
local decimal = require('decimal')
2+
local msgpack = require('msgpack')
3+
4+
-- Do not set listen for now so connector won't be
5+
-- able to send requests until everything is configured.
6+
box.cfg{
7+
work_dir = os.getenv("TEST_TNT_WORK_DIR"),
8+
}
9+
10+
box.schema.user.create('test', { password = 'test' , if_not_exists = true })
11+
box.schema.user.grant('test', 'execute', 'universe', nil, { if_not_exists = true })
12+
13+
local decimal_msgpack_supported = pcall(msgpack.encode, decimal.new(1))
14+
if not decimal_msgpack_supported then
15+
error('Decimal unsupported, use Tarantool 2.2 or newer')
16+
end
17+
18+
local s = box.schema.space.create('testDecimal', {
19+
id = 524,
20+
if_not_exists = true,
21+
})
22+
s:create_index('primary', {
23+
type = 'TREE',
24+
parts = {
25+
{
26+
field = 1,
27+
type = 'decimal',
28+
},
29+
},
30+
if_not_exists = true
31+
})
32+
s:truncate()
33+
34+
box.schema.user.grant('test', 'read,write', 'space', 'testDecimal', { if_not_exists = true })
35+
36+
-- Set listen only when every other thing is configured.
37+
box.cfg{
38+
listen = os.getenv("TEST_TNT_LISTEN"),
39+
}
40+
41+
require('console').start()

decimal/decimal.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Package decimal with support of Tarantool's decimal data type.
2+
//
3+
// Decimal data type supported in Tarantool since 2.2.
4+
//
5+
// Since: 1.6
6+
//
7+
// See also:
8+
//
9+
// * Tarantool MessagePack extensions https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-decimal-type
10+
//
11+
// * Tarantool data model https://www.tarantool.io/en/doc/latest/book/box/data_model/
12+
//
13+
// * Tarantool issue for support decimal type https://github.com/tarantool/tarantool/issues/692
14+
package decimal
15+
16+
import (
17+
"fmt"
18+
"reflect"
19+
20+
"github.com/shopspring/decimal"
21+
"gopkg.in/vmihailenco/msgpack.v2"
22+
)
23+
24+
// Decimal external type.
25+
const decimalExtID = 1
26+
27+
func encodeDecimal(e *msgpack.Encoder, v reflect.Value) error {
28+
number := v.Interface().(decimal.Decimal)
29+
strBuf := number.String()
30+
bcdBuf, err := EncodeStringToBCD(strBuf)
31+
if err != nil {
32+
return fmt.Errorf("msgpack: can't encode string (%s) to a BCD buffer: %w", strBuf, err)
33+
}
34+
if _, err = e.Writer().Write(bcdBuf); err != nil {
35+
return fmt.Errorf("msgpack: can't write bytes to encoder writer: %w", err)
36+
}
37+
38+
return nil
39+
}
40+
41+
// Decimal values can be encoded to fixext MessagePack, where buffer
42+
// has a fixed length encoded by first byte, and ext MessagePack, where
43+
// buffer length is not fixed and encoded by a number in a separate
44+
// field:
45+
//
46+
// +--------+-------------------+------------+===============+
47+
// | MP_EXT | length (optional) | MP_DECIMAL | PackedDecimal |
48+
// +--------+-------------------+------------+===============+
49+
//
50+
// Before reading a buffer with encoded decimal number (PackedDecimal) we need
51+
// to allocate it, but before reading we don't know it's exact size.
52+
// msgpack.Decoder in msgpack v2 package pass a buffer with PackedDecimal bytes
53+
// and there is no possibility to read length in advance. to obtain MessagePack
54+
// length in advance. For example on attempt to decode MessagePack buffer
55+
// c7030100088c (88) msgpack pass 00088a to decodeDecimal() and length field
56+
// value (03) is unavailable nor in buffer nor via decoder interface.
57+
//
58+
// To solve a problem we allocate a buffer with maximum size (it is 32 bytes
59+
// and it is corresponds to ext32 MessagePack), then read encoded decimal to a
60+
// buffer and finally cut off unused part.
61+
func decodeDecimal(d *msgpack.Decoder, v reflect.Value) error {
62+
var maxBytesCount int = 32
63+
b := make([]byte, maxBytesCount)
64+
65+
readBytes, err := d.Buffered().Read(b)
66+
if err != nil {
67+
return fmt.Errorf("msgpack: can't read bytes on decimal decode: %w", err)
68+
}
69+
b = b[:readBytes]
70+
digits, err := DecodeStringFromBCD(b)
71+
if err != nil {
72+
return fmt.Errorf("msgpack: can't decode string from BCD buffer (%x): %w", b, err)
73+
}
74+
dec, err := decimal.NewFromString(digits)
75+
if err != nil {
76+
return fmt.Errorf("msgpack: can't encode string (%s) to a decimal number: %w", digits, err)
77+
}
78+
79+
v.Set(reflect.ValueOf(dec))
80+
81+
return nil
82+
}
83+
84+
func init() {
85+
msgpack.Register(reflect.TypeOf((*decimal.Decimal)(nil)).Elem(), encodeDecimal, decodeDecimal)
86+
msgpack.RegisterExt(decimalExtID, (*decimal.Decimal)(nil))
87+
}

0 commit comments

Comments
 (0)