Skip to content

Commit dc15bfb

Browse files
committed
feat: support decimal
1 parent be587ba commit dc15bfb

16 files changed

+1089
-107
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,37 @@ will now also serialize ``float64`` (double-precision) columns as binary.
188188
You might see a performance uplift if this is a dominant data type in your
189189
ingestion workload.
190190

191+
## Decimal columns
192+
193+
QuestDB server version 9.2.0 and newer supports decimal columns with arbitrary precision and scale.
194+
The Go client converts supported decimal values to QuestDB's text/binary wire format automatically, pass any of the following to `DecimalColumn`:
195+
196+
- `questdb.ScaledDecimal`, including helpers like `questdb.NewDecimalFromInt64` and `questdb.NewDecimal`.
197+
- Types implementing `questdb.DecimalMarshaler`.
198+
- `github.com/shopspring/decimal.Decimal` values or pointers.
199+
- `nil` or `questdb.NullDecimal()` to send a `NULL`.
200+
201+
```go
202+
price := qdb.NewDecimalFromInt64(12345, 2) // 123.45 with scale 2
203+
commission := qdb.NewDecimal(big.NewInt(-750), 4) // -0.0750 with scale 4
204+
205+
err = sender.
206+
Table("trades").
207+
Symbol("symbol", "ETH-USD").
208+
DecimalColumn("price", price).
209+
DecimalColumn("commission", commission).
210+
AtNow(ctx)
211+
```
212+
213+
To emit textual decimals, pass a validated string literal (without the trailing `d`—the client adds it):
214+
215+
```go
216+
err = sender.
217+
Table("quotes").
218+
DecimalColumn("mid", "1.23456").
219+
AtNow(ctx)
220+
```
221+
191222
## Pooled Line Senders
192223

193224
**Warning: Experimental feature designed for use with HTTP senders ONLY**

buffer.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,47 @@ func (b *buffer) Float64Column(name string, val float64) *buffer {
573573
return b
574574
}
575575

576+
func (b *buffer) DecimalColumn(name string, val any) *buffer {
577+
if !b.prepareForField() {
578+
return b
579+
}
580+
b.lastErr = b.writeColumnName(name)
581+
if b.lastErr != nil {
582+
return b
583+
}
584+
b.WriteByte('=')
585+
if str, ok := val.(string); ok {
586+
if err := validateDecimalText(str); err != nil {
587+
b.lastErr = err
588+
return b
589+
}
590+
b.WriteString(str)
591+
b.WriteByte('d')
592+
b.hasFields = true
593+
return b
594+
}
595+
596+
dec, err := normalizeDecimalValue(val)
597+
if err != nil {
598+
b.lastErr = err
599+
return b
600+
}
601+
scale, payload, err := dec.toBinary()
602+
if err != nil {
603+
b.lastErr = err
604+
return b
605+
}
606+
b.WriteByte('=')
607+
b.WriteByte(decimalBinaryTypeCode)
608+
b.WriteByte(scale)
609+
b.WriteByte(byte(len(payload)))
610+
if len(payload) > 0 {
611+
b.Write(payload)
612+
}
613+
b.hasFields = true
614+
return b
615+
}
616+
576617
func (b *buffer) Float64ColumnBinary(name string, val float64) *buffer {
577618
if !b.prepareForField() {
578619
return b

buffer_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,23 @@ import (
3737
"github.com/stretchr/testify/assert"
3838
)
3939

40+
const decimalTypeCode byte = 0x17
41+
4042
type bufWriterFn func(b *qdb.Buffer) error
4143

44+
type fakeShopspringDecimal struct {
45+
coeff *big.Int
46+
exp int32
47+
}
48+
49+
func (f fakeShopspringDecimal) Coefficient() *big.Int {
50+
return f.coeff
51+
}
52+
53+
func (f fakeShopspringDecimal) Exponent() int32 {
54+
return f.exp
55+
}
56+
4257
func newTestBuffer() qdb.Buffer {
4358
return qdb.NewBuffer(128*1024, 1024*1024, 127)
4459
}
@@ -481,6 +496,132 @@ func TestFloat64ColumnBinary(t *testing.T) {
481496
}
482497
}
483498

499+
func TestDecimalColumnText(t *testing.T) {
500+
prefix := []byte(testTable + " price==")
501+
testCases := []struct {
502+
name string
503+
value any
504+
expected []byte
505+
}{
506+
{
507+
name: "positive",
508+
value: qdb.NewDecimalFromInt64(12345, 2),
509+
expected: append(prefix, 0x17, 0x02, 0x02, 0x30, 0x39, 0x0A),
510+
},
511+
{
512+
name: "negative",
513+
value: qdb.NewDecimal(big.NewInt(-12345), 3),
514+
expected: append(prefix, 0x17, 0x03, 0x02, 0xCF, 0xC7, 0x0A),
515+
},
516+
{
517+
name: "zero with scale",
518+
value: qdb.NewDecimalFromInt64(0, 4),
519+
expected: append(prefix, 0x17, 0x04, 0x01, 0x0, 0x0A),
520+
},
521+
{
522+
name: "null decimal",
523+
value: qdb.NullDecimal(),
524+
expected: append(prefix, 0x17, 0x0, 0x0, 0x0A),
525+
},
526+
{
527+
name: "shopspring compatible",
528+
value: fakeShopspringDecimal{coeff: big.NewInt(123456), exp: -4},
529+
expected: append(prefix, 0x17, 0x04, 0x03, 0x01, 0xE2, 0x40, 0x0A),
530+
},
531+
{
532+
name: "nil pointer treated as null",
533+
value: (*fakeShopspringDecimal)(nil),
534+
expected: append(prefix, 0x17, 0x0, 0x0, 0x0A),
535+
},
536+
}
537+
538+
for _, tc := range testCases {
539+
t.Run(tc.name, func(t *testing.T) {
540+
buf := newTestBuffer()
541+
err := buf.Table(testTable).DecimalColumn("price", tc.value).At(time.Time{}, false)
542+
assert.NoError(t, err)
543+
assert.Equal(t, tc.expected, buf.Messages())
544+
})
545+
}
546+
}
547+
548+
func TestDecimalColumnStringValidation(t *testing.T) {
549+
t.Run("valid strings", func(t *testing.T) {
550+
testCases := []struct {
551+
name string
552+
value string
553+
expected string
554+
}{
555+
{"integer", "123", "123d"},
556+
{"decimal", "123.450", "123.450d"},
557+
{"negative", "-0.001", "-0.001d"},
558+
{"exponent positive", "1.2e3", "1.2e3d"},
559+
{"exponent negative", "-4.5E-2", "-4.5E-2d"},
560+
{"nan token", "NaN", "NaNd"},
561+
{"infinity token", "Infinity", "Infinityd"},
562+
}
563+
for _, tc := range testCases {
564+
t.Run(tc.name, func(t *testing.T) {
565+
buf := newTestBuffer()
566+
err := buf.Table(testTable).DecimalColumn("price", tc.value).At(time.Time{}, false)
567+
assert.NoError(t, err)
568+
expected := []byte(testTable + " price=" + tc.expected + "\n")
569+
assert.Equal(t, expected, buf.Messages())
570+
})
571+
}
572+
})
573+
574+
t.Run("invalid strings", func(t *testing.T) {
575+
testCases := []struct {
576+
name string
577+
value string
578+
}{
579+
{"empty", ""},
580+
{"sign only", "+"},
581+
{"double dot", "12.3.4"},
582+
{"invalid char", "12a3"},
583+
{"exponent missing mantissa", "e10"},
584+
{"exponent no digits", "1.2e"},
585+
{"exponent sign no digits", "1.2e+"},
586+
}
587+
for _, tc := range testCases {
588+
t.Run(tc.name, func(t *testing.T) {
589+
buf := newTestBuffer()
590+
err := buf.Table(testTable).DecimalColumn("price", tc.value).At(time.Time{}, false)
591+
assert.Error(t, err)
592+
assert.Contains(t, err.Error(), "decimal")
593+
assert.Empty(t, buf.Messages())
594+
})
595+
}
596+
})
597+
}
598+
599+
func TestDecimalColumnErrors(t *testing.T) {
600+
t.Run("invalid scale", func(t *testing.T) {
601+
buf := newTestBuffer()
602+
dec := qdb.NewDecimalFromInt64(1, 100)
603+
err := buf.Table(testTable).DecimalColumn("price", dec).At(time.Time{}, false)
604+
assert.ErrorContains(t, err, "decimal scale")
605+
assert.Empty(t, buf.Messages())
606+
})
607+
608+
t.Run("overflow", func(t *testing.T) {
609+
buf := newTestBuffer()
610+
bigVal := new(big.Int).Lsh(big.NewInt(1), 2100)
611+
dec := qdb.NewDecimal(bigVal, 0)
612+
err := buf.Table(testTable).DecimalColumn("price", dec).At(time.Time{}, false)
613+
assert.ErrorContains(t, err, "exceeds 256-bit range")
614+
assert.Empty(t, buf.Messages())
615+
})
616+
617+
t.Run("unsupported type", func(t *testing.T) {
618+
buf := newTestBuffer()
619+
err := buf.Table(testTable).DecimalColumn("price", struct{}{}).At(time.Time{}, false)
620+
assert.ErrorContains(t, err, "unsupported decimal column value type")
621+
assert.Empty(t, buf.Messages())
622+
})
623+
}
624+
484625
func TestFloat64Array1DColumn(t *testing.T) {
485626
testCases := []struct {
486627
name string

conf_parse.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,8 +169,8 @@ func confFromStr(conf string) (*lineSenderConfig, error) {
169169
return nil, NewInvalidConfigStrError("invalid %s value, %q is not a valid int", k, v)
170170
}
171171
pVersion := protocolVersion(version)
172-
if pVersion < ProtocolVersion1 || pVersion > ProtocolVersion2 {
173-
return nil, NewInvalidConfigStrError("current client only supports protocol version 1 (text format for all datatypes), 2 (binary format for part datatypes) or explicitly unset")
172+
if pVersion < ProtocolVersion1 || pVersion > ProtocolVersion3 {
173+
return nil, NewInvalidConfigStrError("current client only supports protocol version 1 (text format for all datatypes), 2 (binary format for part datatypes), 3 (decimals) or explicitly unset")
174174
}
175175
senderConf.protocolVersion = pVersion
176176
}

0 commit comments

Comments
 (0)