Skip to content

Commit d2b6670

Browse files
committed
refactor: rework decimal integration to conform to other types
1 parent a1c3981 commit d2b6670

13 files changed

+361
-235
lines changed

README.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,11 @@ ingestion workload.
191191
## Decimal columns
192192

193193
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`:
194+
The Go client converts supported decimal values to QuestDB's text/binary wire format automatically:
195195

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`.
196+
- `DecimalColumnScaled`: `questdb.ScaledDecimal`, including helpers like `questdb.NewDecimalFromInt64` and `questdb.NewDecimal`.
197+
- `DecimalColumnShopspring`: `github.com/shopspring/decimal.Decimal` values or pointers.
198+
- `DecimalColumnString`: `string` literals representing decimal values (validated at runtime).
200199

201200
```go
202201
price := qdb.NewDecimalFromInt64(12345, 2) // 123.45 with scale 2
@@ -205,17 +204,17 @@ commission := qdb.NewDecimal(big.NewInt(-750), 4) // -0.0750 with scale 4
205204
err = sender.
206205
Table("trades").
207206
Symbol("symbol", "ETH-USD").
208-
DecimalColumn("price", price).
209-
DecimalColumn("commission", commission).
207+
DecimalColumnScaled("price", price).
208+
DecimalColumnScaled("commission", commission).
210209
AtNow(ctx)
211210
```
212211

213-
To emit textual decimals, pass a validated string literal (without the trailing `d`—the client adds it):
212+
To emit textual decimals, pass a validated string literal:
214213

215214
```go
216215
err = sender.
217216
Table("quotes").
218-
DecimalColumn("mid", "1.23456").
217+
DecimalColumnString("mid", "1.23456").
219218
AtNow(ctx)
220219
```
221220

buffer.go

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

576-
func (b *buffer) DecimalColumn(name string, val any) *buffer {
576+
func (b *buffer) DecimalColumnScaled(name string, val ScaledDecimal) *buffer {
577577
if !b.prepareForField() {
578578
return b
579579
}
580-
b.lastErr = b.writeColumnName(name)
581-
if b.lastErr != nil {
580+
return b.decimalColumnScaled(name, val)
581+
}
582+
583+
func (b *buffer) decimalColumnScaled(name string, val ScaledDecimal) *buffer {
584+
if err := val.ensureValidScale(); err != nil {
585+
b.lastErr = err
582586
return b
583587
}
584-
if str, ok := val.(string); ok {
585-
if err := validateDecimalText(str); err != nil {
586-
b.lastErr = err
587-
return b
588-
}
589-
b.WriteByte('=')
590-
b.WriteString(str)
591-
b.WriteByte('d')
592-
b.hasFields = true
588+
if val.IsNull() {
589+
// Don't write null decimals
590+
return b
591+
}
592+
b.lastErr = b.writeColumnName(name)
593+
if b.lastErr != nil {
593594
return b
594595
}
596+
b.WriteByte('=')
597+
b.WriteByte('=')
598+
b.WriteByte(decimalBinaryTypeCode)
599+
b.WriteByte((uint8)(val.scale))
600+
b.WriteByte(32 - val.offset)
601+
b.Write(val.unscaled[val.offset:])
602+
b.hasFields = true
603+
return b
604+
}
595605

596-
dec, err := normalizeDecimalValue(val)
597-
if err != nil {
598-
b.lastErr = err
606+
func (b *buffer) DecimalColumnString(name string, val string) *buffer {
607+
if !b.prepareForField() {
599608
return b
600609
}
601-
scale, payload, err := dec.toBinary()
602-
if err != nil {
610+
if err := validateDecimalText(val); err != nil {
603611
b.lastErr = err
604612
return b
605613
}
606-
if len(payload) == 0 {
607-
// Don't write null decimals
614+
b.lastErr = b.writeColumnName(name)
615+
if b.lastErr != nil {
608616
return b
609617
}
610618
b.WriteByte('=')
611-
b.WriteByte('=')
612-
b.WriteByte(decimalBinaryTypeCode)
613-
b.WriteByte(scale)
614-
b.WriteByte(byte(len(payload)))
615-
if len(payload) > 0 {
616-
b.Write(payload)
617-
}
619+
b.WriteString(val)
620+
b.WriteByte('d')
618621
b.hasFields = true
619622
return b
620623
}
621624

625+
func (b *buffer) DecimalColumnShopspring(name string, val ShopspringDecimal) *buffer {
626+
if !b.prepareForField() {
627+
return b
628+
}
629+
if val == nil {
630+
return b
631+
}
632+
dec, err := convertShopspringDecimal(val)
633+
if err != nil {
634+
b.lastErr = err
635+
return b
636+
}
637+
return b.decimalColumnScaled(name, dec)
638+
}
639+
622640
func (b *buffer) Float64ColumnBinary(name string, val float64) *buffer {
623641
if !b.prepareForField() {
624642
return b

buffer_test.go

Lines changed: 126 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -494,11 +494,14 @@ func TestFloat64ColumnBinary(t *testing.T) {
494494
}
495495
}
496496

497-
func TestDecimalColumnText(t *testing.T) {
497+
func TestDecimalColumnScaled(t *testing.T) {
498+
negative, err := qdb.NewDecimal(big.NewInt(-12345), 3)
499+
assert.NoError(t, err)
500+
498501
prefix := []byte(testTable + " price==")
499502
testCases := []struct {
500503
name string
501-
value any
504+
value qdb.ScaledDecimal
502505
expected []byte
503506
}{
504507
{
@@ -508,37 +511,140 @@ func TestDecimalColumnText(t *testing.T) {
508511
},
509512
{
510513
name: "negative",
511-
value: qdb.NewDecimal(big.NewInt(-12345), 3),
514+
value: negative,
512515
expected: append(prefix, 0x17, 0x03, 0x02, 0xCF, 0xC7, 0x0A),
513516
},
514517
{
515518
name: "zero with scale",
516519
value: qdb.NewDecimalFromInt64(0, 4),
517520
expected: append(prefix, 0x17, 0x04, 0x01, 0x0, 0x0A),
518521
},
522+
}
523+
524+
for _, tc := range testCases {
525+
t.Run(tc.name, func(t *testing.T) {
526+
buf := newTestBuffer()
527+
err := buf.Table(testTable).DecimalColumnScaled("price", tc.value).At(time.Time{}, false)
528+
assert.NoError(t, err)
529+
assert.Equal(t, tc.expected, buf.Messages())
530+
})
531+
}
532+
}
533+
534+
func TestDecimalColumnScaledTrimmingAndPadding(t *testing.T) {
535+
prefix := []byte(testTable + " price==")
536+
537+
testCases := []struct {
538+
name string
539+
value qdb.ScaledDecimal
540+
expectedBytes []byte
541+
}{
542+
{
543+
name: "127 boundary",
544+
value: qdb.NewDecimalFromInt64(127, 0),
545+
expectedBytes: []byte{0x17, 0x00, 0x01, 0x7F},
546+
},
547+
{
548+
name: "128 sign extension",
549+
value: qdb.NewDecimalFromInt64(128, 0),
550+
expectedBytes: []byte{0x17, 0x00, 0x02, 0x00, 0x80},
551+
},
552+
{
553+
name: "255 sign extension",
554+
value: qdb.NewDecimalFromInt64(255, 0),
555+
expectedBytes: []byte{0x17, 0x00, 0x02, 0x00, 0xFF},
556+
},
557+
{
558+
name: "32768 sign extension",
559+
value: qdb.NewDecimalFromInt64(32768, 0),
560+
expectedBytes: []byte{0x17, 0x00, 0x03, 0x00, 0x80, 0x00},
561+
},
519562
{
520-
name: "null decimal",
521-
value: qdb.NullDecimal(),
522-
expected: append(prefix, 0x17, 0x0, 0x0, 0x0A),
563+
name: "-1",
564+
value: qdb.NewDecimalFromInt64(-1, 0),
565+
expectedBytes: []byte{0x17, 0x00, 0x01, 0xFF},
523566
},
524567
{
525-
name: "shopspring compatible",
526-
value: fakeShopspringDecimal{coeff: big.NewInt(123456), exp: -4},
527-
expected: append(prefix, 0x17, 0x04, 0x03, 0x01, 0xE2, 0x40, 0x0A),
568+
name: "-2",
569+
value: qdb.NewDecimalFromInt64(-2, 0),
570+
expectedBytes: []byte{0x17, 0x00, 0x01, 0xFE},
528571
},
529572
{
530-
name: "nil pointer treated as null",
531-
value: (*fakeShopspringDecimal)(nil),
532-
expected: append(prefix, 0x17, 0x0, 0x0, 0x0A),
573+
name: "-127",
574+
value: qdb.NewDecimalFromInt64(-127, 0),
575+
expectedBytes: []byte{0x17, 0x00, 0x01, 0x81},
576+
},
577+
{
578+
name: "-128",
579+
value: qdb.NewDecimalFromInt64(-128, 0),
580+
expectedBytes: []byte{0x17, 0x00, 0x01, 0x80},
581+
},
582+
{
583+
name: "-129",
584+
value: qdb.NewDecimalFromInt64(-129, 0),
585+
expectedBytes: []byte{0x17, 0x00, 0x02, 0xFF, 0x7F},
586+
},
587+
{
588+
name: "-256 sign extension",
589+
value: qdb.NewDecimalFromInt64(-256, 0),
590+
expectedBytes: []byte{0x17, 0x00, 0x02, 0xFF, 0x00},
533591
},
534592
}
535593

536594
for _, tc := range testCases {
537595
t.Run(tc.name, func(t *testing.T) {
538596
buf := newTestBuffer()
539-
err := buf.Table(testTable).DecimalColumn("price", tc.value).At(time.Time{}, false)
597+
598+
err := buf.Table(testTable).DecimalColumnScaled("price", tc.value).At(time.Time{}, false)
540599
assert.NoError(t, err)
541-
assert.Equal(t, tc.expected, buf.Messages())
600+
601+
expected := append(append([]byte{}, prefix...), tc.expectedBytes...)
602+
expected = append(expected, '\n')
603+
assert.Equal(t, expected, buf.Messages())
604+
})
605+
}
606+
}
607+
608+
func TestDecimalColumnShopspring(t *testing.T) {
609+
prefix := []byte(testTable + " price==")
610+
611+
testCases := []struct {
612+
name string
613+
value fakeShopspringDecimal
614+
expectedBytes []byte
615+
}{
616+
{
617+
name: "negative exponent scales value",
618+
value: fakeShopspringDecimal{coeff: big.NewInt(12345), exp: -2},
619+
expectedBytes: []byte{0x17, 0x02, 0x02, 0x30, 0x39},
620+
},
621+
{
622+
name: "positive exponent multiplies coefficient",
623+
value: fakeShopspringDecimal{coeff: big.NewInt(123), exp: 2},
624+
expectedBytes: []byte{0x17, 0x00, 0x02, 0x30, 0x0C},
625+
},
626+
{
627+
name: "positive value sign extension",
628+
value: fakeShopspringDecimal{coeff: big.NewInt(128), exp: 0},
629+
expectedBytes: []byte{0x17, 0x00, 0x02, 0x00, 0x80},
630+
},
631+
{
632+
name: "negative value sign extension",
633+
value: fakeShopspringDecimal{coeff: big.NewInt(-12345), exp: -3},
634+
expectedBytes: []byte{0x17, 0x03, 0x02, 0xCF, 0xC7},
635+
},
636+
}
637+
638+
for _, tc := range testCases {
639+
t.Run(tc.name, func(t *testing.T) {
640+
buf := newTestBuffer()
641+
642+
err := buf.Table(testTable).DecimalColumnShopspring("price", tc.value).At(time.Time{}, false)
643+
assert.NoError(t, err)
644+
645+
expected := append(append([]byte{}, prefix...), tc.expectedBytes...)
646+
expected = append(expected, '\n')
647+
assert.Equal(t, expected, buf.Messages())
542648
})
543649
}
544650
}
@@ -561,7 +667,7 @@ func TestDecimalColumnStringValidation(t *testing.T) {
561667
for _, tc := range testCases {
562668
t.Run(tc.name, func(t *testing.T) {
563669
buf := newTestBuffer()
564-
err := buf.Table(testTable).DecimalColumn("price", tc.value).At(time.Time{}, false)
670+
err := buf.Table(testTable).DecimalColumnString("price", tc.value).At(time.Time{}, false)
565671
assert.NoError(t, err)
566672
expected := []byte(testTable + " price=" + tc.expected + "\n")
567673
assert.Equal(t, expected, buf.Messages())
@@ -585,7 +691,7 @@ func TestDecimalColumnStringValidation(t *testing.T) {
585691
for _, tc := range testCases {
586692
t.Run(tc.name, func(t *testing.T) {
587693
buf := newTestBuffer()
588-
err := buf.Table(testTable).DecimalColumn("price", tc.value).At(time.Time{}, false)
694+
err := buf.Table(testTable).DecimalColumnString("price", tc.value).At(time.Time{}, false)
589695
assert.Error(t, err)
590696
assert.Contains(t, err.Error(), "decimal")
591697
assert.Empty(t, buf.Messages())
@@ -598,30 +704,20 @@ func TestDecimalColumnErrors(t *testing.T) {
598704
t.Run("invalid scale", func(t *testing.T) {
599705
buf := newTestBuffer()
600706
dec := qdb.NewDecimalFromInt64(1, 100)
601-
err := buf.Table(testTable).DecimalColumn("price", dec).At(time.Time{}, false)
707+
err := buf.Table(testTable).DecimalColumnScaled("price", dec).At(time.Time{}, false)
602708
assert.ErrorContains(t, err, "decimal scale")
603709
assert.Empty(t, buf.Messages())
604710
})
605711

606712
t.Run("overflow", func(t *testing.T) {
607-
buf := newTestBuffer()
608713
bigVal := new(big.Int).Lsh(big.NewInt(1), 2100)
609-
dec := qdb.NewDecimal(bigVal, 0)
610-
err := buf.Table(testTable).DecimalColumn("price", dec).At(time.Time{}, false)
611-
assert.ErrorContains(t, err, "exceeds 127-bytes limit")
612-
assert.Empty(t, buf.Messages())
613-
})
614-
615-
t.Run("unsupported type", func(t *testing.T) {
616-
buf := newTestBuffer()
617-
err := buf.Table(testTable).DecimalColumn("price", struct{}{}).At(time.Time{}, false)
618-
assert.ErrorContains(t, err, "unsupported decimal column value type")
619-
assert.Empty(t, buf.Messages())
714+
_, err := qdb.NewDecimal(bigVal, 0)
715+
assert.ErrorContains(t, err, "exceeds 32 bytes")
620716
})
621717

622718
t.Run("no column", func(t *testing.T) {
623719
buf := newTestBuffer()
624-
err := buf.Table(testTable).DecimalColumn("price", qdb.NullDecimal()).At(time.Time{}, false)
720+
err := buf.Table(testTable).DecimalColumnShopspring("price", nil).At(time.Time{}, false)
625721
assert.ErrorContains(t, err, "no symbols or columns were provided: invalid message")
626722
assert.Empty(t, buf.Messages())
627723
})

0 commit comments

Comments
 (0)