Files
2026-07-13 12:32:25 +08:00

350 lines
11 KiB
Go

// 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
}