Skip to content

Commit 31bc2e9

Browse files
authored
Merge pull request #2876 from dolthub/fulghum/bugfix-jsonb
2 parents e77bbc3 + 3d02679 commit 31bc2e9

3 files changed

Lines changed: 326 additions & 2 deletions

File tree

server/types/jsonb.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package types
1616

1717
import (
18+
"github.com/cockroachdb/apd/v3"
1819
"github.com/cockroachdb/errors"
1920
"github.com/dolthub/go-mysql-server/sql"
2021
gmstypes "github.com/dolthub/go-mysql-server/sql/types"
@@ -125,7 +126,8 @@ func jsonValueToInterface(value JsonValue) any {
125126
case JsonValueString:
126127
return string(v)
127128
case JsonValueNumber:
128-
return v
129+
d := apd.Decimal(v)
130+
return &d
129131
case JsonValueBoolean:
130132
return bool(v)
131133
case JsonValueNull:

server/types/numeric.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package types
1616

1717
import (
1818
"math"
19+
"math/big"
1920

2021
"github.com/cockroachdb/apd/v3"
2122
"github.com/cockroachdb/errors"
@@ -168,7 +169,18 @@ func deserializeTypeNumeric(ctx *sql.Context, t *DoltgresType, data []byte) (any
168169
if err != nil {
169170
return nil, err
170171
}
171-
return apd.New(retVal.CoefficientInt64(), retVal.Exponent()), nil
172+
// Coefficient() returns a *big.Int; CoefficientInt64() would silently overflow for
173+
// values whose coefficient exceeds int64 (e.g. large JSONB numbers in legacy format).
174+
coeff := retVal.Coefficient()
175+
d := new(apd.Decimal)
176+
d.Exponent = retVal.Exponent()
177+
if coeff.Sign() < 0 {
178+
d.Negative = true
179+
d.Coeff.SetMathBigInt(new(big.Int).Neg(coeff))
180+
} else {
181+
d.Coeff.SetMathBigInt(coeff)
182+
}
183+
return d, nil
172184
}
173185

174186
// NumericCompare compares two *apd.Decimal values handling NaN separately.

testing/go/jsonb_test.go

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
// Copyright 2026 Dolthub, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package _go
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"testing"
22+
23+
"github.com/cockroachdb/apd/v3"
24+
gmstypes "github.com/dolthub/go-mysql-server/sql/types"
25+
"github.com/stretchr/testify/require"
26+
27+
"github.com/dolthub/doltgresql/server/types"
28+
)
29+
30+
// TestLegacyJsonBRoundTrip verifies that JSONB values written in the old ExtendedEnc format
31+
// (used before JsonAdaptiveEnc was introduced for jsonb columns) deserialize correctly.
32+
// Each JSON value type is tested to ensure no internal JsonValue* types leak into the result.
33+
func TestLegacyJsonBRoundTrip(t *testing.T) {
34+
tests := []struct {
35+
name string
36+
input any // value placed into JSONDocument.Val before serialization
37+
expected any // expected JSONDocument.Val after deserialization
38+
}{
39+
// ── Primitives ────────────────────────────────────────────────────────────────
40+
{
41+
name: "null",
42+
input: nil,
43+
expected: nil,
44+
},
45+
{
46+
name: "bool_true",
47+
input: true,
48+
expected: true,
49+
},
50+
{
51+
name: "bool_false",
52+
input: false,
53+
expected: false,
54+
},
55+
{
56+
name: "string",
57+
input: "hello",
58+
expected: "hello",
59+
},
60+
{
61+
name: "string_empty",
62+
input: "",
63+
expected: "",
64+
},
65+
{
66+
name: "string_unicode",
67+
input: "こんにちは",
68+
expected: "こんにちは",
69+
},
70+
// Numbers: the primary compatibility concern — must come back as *apd.Decimal,
71+
// not the internal JsonValueNumber type.
72+
{
73+
name: "number_zero",
74+
input: json.Number("0"),
75+
expected: mustDecimal("0"),
76+
},
77+
{
78+
name: "number_integer",
79+
input: json.Number("42"),
80+
expected: mustDecimal("42"),
81+
},
82+
{
83+
name: "number_negative_integer",
84+
input: json.Number("-7"),
85+
expected: mustDecimal("-7"),
86+
},
87+
{
88+
name: "number_float",
89+
input: json.Number("3.14"),
90+
expected: mustDecimal("3.14"),
91+
},
92+
{
93+
name: "number_negative_float",
94+
input: json.Number("-2.718"),
95+
expected: mustDecimal("-2.718"),
96+
},
97+
{
98+
name: "number_large_integer",
99+
input: json.Number("99999999999999999999"),
100+
expected: mustDecimal("99999999999999999999"),
101+
},
102+
{
103+
name: "number_high_precision",
104+
input: json.Number("1.23456789012345678901"),
105+
expected: mustDecimal("1.23456789012345678901"),
106+
},
107+
// ── Empty containers ──────────────────────────────────────────────────────────
108+
{
109+
name: "object_empty",
110+
input: map[string]any{},
111+
expected: map[string]any{},
112+
},
113+
{
114+
name: "array_empty",
115+
input: []any{},
116+
expected: []any{},
117+
},
118+
// ── Objects: one test per value type ─────────────────────────────────────────
119+
{
120+
name: "object_string_val",
121+
input: map[string]any{"k": "v"},
122+
expected: map[string]any{"k": "v"},
123+
},
124+
{
125+
name: "object_number_val",
126+
input: map[string]any{"n": json.Number("99")},
127+
expected: map[string]any{"n": mustDecimal("99")},
128+
},
129+
{
130+
name: "object_bool_val",
131+
input: map[string]any{"b": true},
132+
expected: map[string]any{"b": true},
133+
},
134+
{
135+
name: "object_null_val",
136+
input: map[string]any{"n": nil},
137+
expected: map[string]any{"n": nil},
138+
},
139+
{
140+
name: "object_mixed_vals",
141+
input: map[string]any{
142+
"s": "hello", "n": json.Number("1.5"), "b": false, "z": nil,
143+
},
144+
expected: map[string]any{
145+
"s": "hello", "n": mustDecimal("1.5"), "b": false, "z": nil,
146+
},
147+
},
148+
// ── Arrays: one test per value type ──────────────────────────────────────────
149+
{
150+
name: "array_strings",
151+
input: []any{"a", "b", "c"},
152+
expected: []any{"a", "b", "c"},
153+
},
154+
{
155+
name: "array_numbers",
156+
input: []any{json.Number("1"), json.Number("2"), json.Number("3")},
157+
expected: []any{mustDecimal("1"), mustDecimal("2"), mustDecimal("3")},
158+
},
159+
{
160+
name: "array_bools",
161+
input: []any{true, false, true},
162+
expected: []any{true, false, true},
163+
},
164+
{
165+
name: "array_nulls",
166+
input: []any{nil, nil},
167+
expected: []any{nil, nil},
168+
},
169+
{
170+
name: "array_mixed",
171+
input: []any{json.Number("1"), "two", true, nil},
172+
expected: []any{mustDecimal("1"), "two", true, nil},
173+
},
174+
// ── Nested structures ─────────────────────────────────────────────────────────
175+
{
176+
name: "object_with_nested_array",
177+
input: map[string]any{
178+
"nums": []any{json.Number("1"), json.Number("2")},
179+
"name": "test",
180+
},
181+
expected: map[string]any{
182+
"nums": []any{mustDecimal("1"), mustDecimal("2")},
183+
"name": "test",
184+
},
185+
},
186+
{
187+
name: "array_of_objects",
188+
input: []any{
189+
map[string]any{"x": json.Number("10")},
190+
map[string]any{"x": json.Number("20")},
191+
},
192+
expected: []any{
193+
map[string]any{"x": mustDecimal("10")},
194+
map[string]any{"x": mustDecimal("20")},
195+
},
196+
},
197+
{
198+
name: "deeply_nested",
199+
input: map[string]any{
200+
"a": map[string]any{
201+
"b": []any{
202+
json.Number("1"),
203+
map[string]any{"c": true, "d": json.Number("-9.9")},
204+
[]any{"x", json.Number("0")},
205+
},
206+
},
207+
},
208+
expected: map[string]any{
209+
"a": map[string]any{
210+
"b": []any{
211+
mustDecimal("1"),
212+
map[string]any{"c": true, "d": mustDecimal("-9.9")},
213+
[]any{"x", mustDecimal("0")},
214+
},
215+
},
216+
},
217+
},
218+
}
219+
220+
for _, tt := range tests {
221+
t.Run(tt.name, func(t *testing.T) {
222+
doc := gmstypes.JSONDocument{Val: tt.input}
223+
224+
// Simulate writing with old Doltgres (ExtendedEnc path calls serializeTypeJsonB).
225+
serialized, err := types.JsonB.SerializeValue(context.Background(), doc)
226+
require.NoError(t, err)
227+
228+
// Simulate reading with new Doltgres (still calls deserializeTypeJsonB for old rows).
229+
result, err := types.JsonB.DeserializeValue(context.Background(), serialized)
230+
require.NoError(t, err)
231+
require.NotNil(t, result)
232+
233+
resultDoc, ok := result.(gmstypes.JSONDocument)
234+
require.True(t, ok)
235+
requireNoInternalJsonTypes(t, resultDoc.Val)
236+
requireJsonEqual(t, tt.expected, resultDoc.Val)
237+
})
238+
}
239+
}
240+
241+
// requireNoInternalJsonTypes recurses through a deserialized JSON value and asserts that no
242+
// internal JsonValue* types have leaked out. Every value must be a native Go type.
243+
func requireNoInternalJsonTypes(t *testing.T, val any) {
244+
t.Helper()
245+
switch v := val.(type) {
246+
case map[string]any:
247+
for _, child := range v {
248+
requireNoInternalJsonTypes(t, child)
249+
}
250+
case []any:
251+
for _, item := range v {
252+
requireNoInternalJsonTypes(t, item)
253+
}
254+
case types.JsonValueNumber:
255+
t.Errorf("number leaked as internal JsonValueNumber; expected *apd.Decimal")
256+
case types.JsonValueString:
257+
t.Errorf("string leaked as internal JsonValueString; expected string")
258+
case types.JsonValueBoolean:
259+
t.Errorf("bool leaked as internal JsonValueBoolean; expected bool")
260+
case types.JsonValueNull:
261+
t.Errorf("null leaked as internal JsonValueNull; expected nil")
262+
case types.JsonValueObject:
263+
t.Errorf("object leaked as internal JsonValueObject; expected map[string]any")
264+
case types.JsonValueArray:
265+
t.Errorf("array leaked as internal JsonValueArray; expected []any")
266+
}
267+
}
268+
269+
// requireJsonEqual recursively compares an expected JSON value tree against actual,
270+
// using string comparison for *apd.Decimal to avoid representation sensitivity.
271+
func requireJsonEqual(t *testing.T, expected, actual any) {
272+
t.Helper()
273+
if expected == nil {
274+
require.Nil(t, actual, "expected nil, got %T: %v", actual, actual)
275+
return
276+
}
277+
switch e := expected.(type) {
278+
case *apd.Decimal:
279+
a, ok := actual.(*apd.Decimal)
280+
require.True(t, ok, "expected *apd.Decimal, got %T: %v", actual, actual)
281+
require.Equal(t, e.String(), a.String())
282+
case map[string]any:
283+
a, ok := actual.(map[string]any)
284+
require.True(t, ok, "expected map[string]any, got %T", actual)
285+
require.Equal(t, len(e), len(a), "object has %d keys, want %d", len(a), len(e))
286+
for k, ev := range e {
287+
av, exists := a[k]
288+
require.True(t, exists, "key %q missing from result", k)
289+
requireJsonEqual(t, ev, av)
290+
}
291+
case []any:
292+
a, ok := actual.([]any)
293+
require.True(t, ok, "expected []any, got %T", actual)
294+
require.Equal(t, len(e), len(a), "array has %d elements, want %d", len(a), len(e))
295+
for i := range e {
296+
requireJsonEqual(t, e[i], a[i])
297+
}
298+
default:
299+
require.Equal(t, expected, actual)
300+
}
301+
}
302+
303+
// mustDecimal parses s into an *apd.Decimal, panicking on failure.
304+
func mustDecimal(s string) *apd.Decimal {
305+
d := new(apd.Decimal)
306+
if err := d.Scan(s); err != nil {
307+
panic(fmt.Sprintf("mustDecimal(%q): %v", s, err))
308+
}
309+
return d
310+
}

0 commit comments

Comments
 (0)