// Copyright 2026 Dolthub, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package _go import ( "context" "encoding/json" "fmt" "testing" "github.com/cockroachdb/apd/v3" gmstypes "github.com/dolthub/go-mysql-server/sql/types" "github.com/stretchr/testify/require" "github.com/dolthub/doltgresql/server/types" ) // TestLegacyJsonBRoundTrip verifies that JSONB values written in the old ExtendedEnc format // (used before JsonAdaptiveEnc was introduced for jsonb columns) deserialize correctly. // Each JSON value type is tested to ensure no internal JsonValue* types leak into the result. func TestLegacyJsonBRoundTrip(t *testing.T) { tests := []struct { name string input any // value placed into JSONDocument.Val before serialization expected any // expected JSONDocument.Val after deserialization }{ // ── Primitives ──────────────────────────────────────────────────────────────── { name: "null", input: nil, expected: nil, }, { name: "bool_true", input: true, expected: true, }, { name: "bool_false", input: false, expected: false, }, { name: "string", input: "hello", expected: "hello", }, { name: "string_empty", input: "", expected: "", }, { name: "string_unicode", input: "こんにちは", expected: "こんにちは", }, // Numbers: the primary compatibility concern — must come back as *apd.Decimal, // not the internal JsonValueNumber type. { name: "number_zero", input: json.Number("0"), expected: mustDecimal("0"), }, { name: "number_integer", input: json.Number("42"), expected: mustDecimal("42"), }, { name: "number_negative_integer", input: json.Number("-7"), expected: mustDecimal("-7"), }, { name: "number_float", input: json.Number("3.14"), expected: mustDecimal("3.14"), }, { name: "number_negative_float", input: json.Number("-2.718"), expected: mustDecimal("-2.718"), }, { name: "number_large_integer", input: json.Number("99999999999999999999"), expected: mustDecimal("99999999999999999999"), }, { name: "number_high_precision", input: json.Number("1.23456789012345678901"), expected: mustDecimal("1.23456789012345678901"), }, // ── Empty containers ────────────────────────────────────────────────────────── { name: "object_empty", input: map[string]any{}, expected: map[string]any{}, }, { name: "array_empty", input: []any{}, expected: []any{}, }, // ── Objects: one test per value type ───────────────────────────────────────── { name: "object_string_val", input: map[string]any{"k": "v"}, expected: map[string]any{"k": "v"}, }, { name: "object_number_val", input: map[string]any{"n": json.Number("99")}, expected: map[string]any{"n": mustDecimal("99")}, }, { name: "object_bool_val", input: map[string]any{"b": true}, expected: map[string]any{"b": true}, }, { name: "object_null_val", input: map[string]any{"n": nil}, expected: map[string]any{"n": nil}, }, { name: "object_mixed_vals", input: map[string]any{ "s": "hello", "n": json.Number("1.5"), "b": false, "z": nil, }, expected: map[string]any{ "s": "hello", "n": mustDecimal("1.5"), "b": false, "z": nil, }, }, // ── Arrays: one test per value type ────────────────────────────────────────── { name: "array_strings", input: []any{"a", "b", "c"}, expected: []any{"a", "b", "c"}, }, { name: "array_numbers", input: []any{json.Number("1"), json.Number("2"), json.Number("3")}, expected: []any{mustDecimal("1"), mustDecimal("2"), mustDecimal("3")}, }, { name: "array_bools", input: []any{true, false, true}, expected: []any{true, false, true}, }, { name: "array_nulls", input: []any{nil, nil}, expected: []any{nil, nil}, }, { name: "array_mixed", input: []any{json.Number("1"), "two", true, nil}, expected: []any{mustDecimal("1"), "two", true, nil}, }, // ── Nested structures ───────────────────────────────────────────────────────── { name: "object_with_nested_array", input: map[string]any{ "nums": []any{json.Number("1"), json.Number("2")}, "name": "test", }, expected: map[string]any{ "nums": []any{mustDecimal("1"), mustDecimal("2")}, "name": "test", }, }, { name: "array_of_objects", input: []any{ map[string]any{"x": json.Number("10")}, map[string]any{"x": json.Number("20")}, }, expected: []any{ map[string]any{"x": mustDecimal("10")}, map[string]any{"x": mustDecimal("20")}, }, }, { name: "deeply_nested", input: map[string]any{ "a": map[string]any{ "b": []any{ json.Number("1"), map[string]any{"c": true, "d": json.Number("-9.9")}, []any{"x", json.Number("0")}, }, }, }, expected: map[string]any{ "a": map[string]any{ "b": []any{ mustDecimal("1"), map[string]any{"c": true, "d": mustDecimal("-9.9")}, []any{"x", mustDecimal("0")}, }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { doc := gmstypes.JSONDocument{Val: tt.input} // Simulate writing with old Doltgres (ExtendedEnc path calls serializeTypeJsonB). serialized, err := types.JsonB.SerializeValue(context.Background(), doc) require.NoError(t, err) // Simulate reading with new Doltgres (still calls deserializeTypeJsonB for old rows). result, err := types.JsonB.DeserializeValue(context.Background(), serialized) require.NoError(t, err) require.NotNil(t, result) resultDoc, ok := result.(gmstypes.JSONDocument) require.True(t, ok) requireNoInternalJsonTypes(t, resultDoc.Val) requireJsonEqual(t, tt.expected, resultDoc.Val) }) } } // requireNoInternalJsonTypes recurses through a deserialized JSON value and asserts that no // internal JsonValue* types have leaked out. Every value must be a native Go type. func requireNoInternalJsonTypes(t *testing.T, val any) { t.Helper() switch v := val.(type) { case map[string]any: for _, child := range v { requireNoInternalJsonTypes(t, child) } case []any: for _, item := range v { requireNoInternalJsonTypes(t, item) } case types.JsonValueNumber: t.Errorf("number leaked as internal JsonValueNumber; expected *apd.Decimal") case types.JsonValueString: t.Errorf("string leaked as internal JsonValueString; expected string") case types.JsonValueBoolean: t.Errorf("bool leaked as internal JsonValueBoolean; expected bool") case types.JsonValueNull: t.Errorf("null leaked as internal JsonValueNull; expected nil") case types.JsonValueObject: t.Errorf("object leaked as internal JsonValueObject; expected map[string]any") case types.JsonValueArray: t.Errorf("array leaked as internal JsonValueArray; expected []any") } } // requireJsonEqual recursively compares an expected JSON value tree against actual, // using string comparison for *apd.Decimal to avoid representation sensitivity. func requireJsonEqual(t *testing.T, expected, actual any) { t.Helper() if expected == nil { require.Nil(t, actual, "expected nil, got %T: %v", actual, actual) return } switch e := expected.(type) { case *apd.Decimal: a, ok := actual.(*apd.Decimal) require.True(t, ok, "expected *apd.Decimal, got %T: %v", actual, actual) require.Equal(t, e.String(), a.String()) case map[string]any: a, ok := actual.(map[string]any) require.True(t, ok, "expected map[string]any, got %T", actual) require.Equal(t, len(e), len(a), "object has %d keys, want %d", len(a), len(e)) for k, ev := range e { av, exists := a[k] require.True(t, exists, "key %q missing from result", k) requireJsonEqual(t, ev, av) } case []any: a, ok := actual.([]any) require.True(t, ok, "expected []any, got %T", actual) require.Equal(t, len(e), len(a), "array has %d elements, want %d", len(a), len(e)) for i := range e { requireJsonEqual(t, e[i], a[i]) } default: require.Equal(t, expected, actual) } } // TestLegacyJsonBReserialization verifies that a value deserialized from the legacy // ExtendedEnc format can be re-serialized without error. This exercises the path where // serializeTypeJsonB receives a JSONDocument whose Val contains *apd.Decimal (the type // that jsonValueToInterface now returns for JsonValueNumber after the bugfix), and // ConvertToJsonDocument must handle it. func TestLegacyJsonBReserialization(t *testing.T) { tests := []struct { name string input any }{ {"number_integer", json.Number("42")}, {"number_negative", json.Number("-7")}, {"number_float", json.Number("3.14")}, {"number_large", json.Number("99999999999999999999")}, {"object_with_number", map[string]any{"n": json.Number("99")}}, {"array_with_numbers", []any{json.Number("1"), json.Number("2"), json.Number("3")}}, {"nested_number", map[string]any{"a": map[string]any{"b": json.Number("1.5")}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { doc := gmstypes.JSONDocument{Val: tt.input} // Step 1: serialize (simulates old Doltgres write using the legacy ExtendedEnc path) serialized, err := types.JsonB.SerializeValue(context.Background(), doc) require.NoError(t, err) // Step 2: deserialize (jsonValueToInterface now returns *apd.Decimal for numbers) deserialized, err := types.JsonB.DeserializeValue(context.Background(), serialized) require.NoError(t, err) require.NotNil(t, deserialized) // Step 3: re-serialize the deserialized value. _, err = types.JsonB.SerializeValue(context.Background(), deserialized) require.NoError(t, err, "re-serializing a deserialized legacy JSONB value must not fail") }) } } // mustDecimal parses s into an *apd.Decimal, panicking on failure. func mustDecimal(s string) *apd.Decimal { d := new(apd.Decimal) if err := d.Scan(s); err != nil { panic(fmt.Sprintf("mustDecimal(%q): %v", s, err)) } return d }