diff --git a/sql_array_types_roundtrip_test.go b/sql_array_types_roundtrip_test.go new file mode 100644 index 0000000..888f4b0 --- /dev/null +++ b/sql_array_types_roundtrip_test.go @@ -0,0 +1,207 @@ +package sqltypes + +import ( + "database/sql/driver" + "encoding/json" + "encoding/xml" + "reflect" + "strings" + "testing" + + "github.com/google/uuid" + "gopkg.in/yaml.v3" +) + +// arrayPtr is the pointer-receiver surface shared by every nullable array type. +type arrayPtr[T any] interface { + *T + Scan(any) error + UnmarshalJSON([]byte) error + UnmarshalYAML(*yaml.Node) error + UnmarshalXML(*xml.Decoder, xml.StartElement) error +} + +// arrayValue is the value-receiver surface shared by every nullable array type. +type arrayValue interface { + Value() (driver.Value, error) + MarshalJSON() ([]byte, error) + MarshalYAML() (any, error) + MarshalXML(*xml.Encoder, xml.StartElement) error +} + +type wrapped[T any] struct { + XMLName xml.Name `yaml:"-" xml:"w"` + V T `yaml:"v" xml:"v"` +} + +// arrayRoundTrip runs the full Scan/Value/JSON/YAML/XML contract for one array type. +// badScan is a literal the type's Scan must reject ("" skips the check). +func arrayRoundTrip[T any, P arrayPtr[T]](t *testing.T, sample T, null T, badScan string) { + t.Helper() + sv, ok := any(sample).(arrayValue) + if !ok { + t.Fatalf("%T does not implement the array value surface", sample) + } + nv := any(null).(arrayValue) + + t.Run("scan-value", func(t *testing.T) { + val, err := sv.Value() + if err != nil || val == nil { + t.Fatalf("Value: %v %v", val, err) + } + for _, in := range []any{val, []byte(val.(string))} { + var got T + if err := P(&got).Scan(in); err != nil { + t.Fatalf("Scan(%T): %v", in, err) + } + if !reflect.DeepEqual(got, sample) { + t.Errorf("Scan(%T) = %+v, want %+v", in, got, sample) + } + } + if v, err := nv.Value(); v != nil || err != nil { + t.Errorf("null Value = %v, %v", v, err) + } + got := sample + if err := P(&got).Scan(nil); err != nil || !reflect.DeepEqual(got, null) { + t.Errorf("Scan(nil) = %+v, %v", got, err) + } + if err := P(&got).Scan(12345); err == nil { + t.Error("Scan(int) must fail") + } + if badScan != "" { + var bad T + if err := P(&bad).Scan(badScan); err == nil { + t.Errorf("Scan(%q) must fail", badScan) + } + } + }) + + t.Run("json", func(t *testing.T) { + b, err := sv.MarshalJSON() + if err != nil { + t.Fatal(err) + } + var got T + if err := P(&got).UnmarshalJSON(b); err != nil || !reflect.DeepEqual(got, sample) { + t.Errorf("round trip = %+v, %v", got, err) + } + nb, _ := nv.MarshalJSON() + if string(nb) != "null" { + t.Errorf("null marshals to %s", nb) + } + got = sample + if err := P(&got).UnmarshalJSON([]byte(" null ")); err != nil || !reflect.DeepEqual(got, null) { + t.Errorf("null unmarshal = %+v, %v", got, err) + } + if err := P(&got).UnmarshalJSON([]byte(`{}`)); err == nil { + t.Error("object must be rejected") + } + }) + + t.Run("yaml", func(t *testing.T) { + b, err := yaml.Marshal(wrapped[T]{V: sample}) + if err != nil { + t.Fatal(err) + } + var got wrapped[T] + if err := yaml.Unmarshal(b, &got); err != nil || !reflect.DeepEqual(got.V, sample) { + t.Errorf("round trip = %+v, %v\n%s", got.V, err, b) + } + nb, err := yaml.Marshal(wrapped[T]{V: null}) + if err != nil || !strings.Contains(string(nb), "null") { + t.Errorf("null marshal = %q, %v", nb, err) + } + // yaml.v3 skips UnmarshalYAML for null, so decode into a fresh value. + got = wrapped[T]{} + if err := yaml.Unmarshal(nb, &got); err != nil || !reflect.DeepEqual(got.V, null) { + t.Errorf("null unmarshal = %+v, %v", got.V, err) + } + var bad wrapped[T] + if err := yaml.Unmarshal([]byte("v: {a: b}\n"), &bad); err == nil { + t.Error("mapping must be rejected") + } + }) + + t.Run("xml", func(t *testing.T) { + b, err := xml.Marshal(wrapped[T]{V: sample}) + if err != nil { + t.Fatal(err) + } + var got wrapped[T] + if err := xml.Unmarshal(b, &got); err != nil || !reflect.DeepEqual(got.V, sample) { + t.Errorf("round trip = %+v, %v\n%s", got.V, err, b) + } + if _, err := xml.Marshal(wrapped[T]{V: null}); err != nil { + t.Errorf("null marshal: %v", err) + } + var bad wrapped[T] + if err := xml.Unmarshal([]byte("1"), &bad); err == nil { + t.Error("truncated xml must fail") + } + }) +} + +func TestArrayTypes_FullContract(t *testing.T) { + u1, u2 := uuid.New(), uuid.New() + t.Run("string", func(t *testing.T) { + arrayRoundTrip(t, NewSqlStringArray([]string{"a", "b c", `q"uote`, "x,y"}), SqlStringArray{}, "") + }) + t.Run("int16", func(t *testing.T) { + arrayRoundTrip(t, NewSqlInt16Array([]int16{1, -2, 300}), SqlInt16Array{}, "{99999}") + }) + t.Run("int32", func(t *testing.T) { + arrayRoundTrip(t, NewSqlInt32Array([]int32{1, -2, 300000}), SqlInt32Array{}, "{x}") + }) + t.Run("int64", func(t *testing.T) { + arrayRoundTrip(t, NewSqlInt64Array([]int64{1, -2, 1 << 40}), SqlInt64Array{}, "{x}") + }) + t.Run("float32", func(t *testing.T) { + arrayRoundTrip(t, NewSqlFloat32Array([]float32{1.5, -2.25}), SqlFloat32Array{}, "{x}") + }) + t.Run("float64", func(t *testing.T) { + arrayRoundTrip(t, NewSqlFloat64Array([]float64{1.5, -2.25, 1e10}), SqlFloat64Array{}, "{x}") + }) + t.Run("bool", func(t *testing.T) { + arrayRoundTrip(t, NewSqlBoolArray([]bool{true, false, true}), SqlBoolArray{}, "not an array") + }) + t.Run("uuid", func(t *testing.T) { + arrayRoundTrip(t, NewSqlUUIDArray([]uuid.UUID{u1, u2}), SqlUUIDArray{}, "{not-a-uuid}") + }) + t.Run("vector", func(t *testing.T) { + arrayRoundTrip(t, NewSqlVector([]float32{1, 2.5, -3}), SqlVector{}, "1,2,3") + }) +} + +func TestArrayTypes_EmptyAndMalformedScan(t *testing.T) { + var s SqlStringArray + if err := s.Scan("{}"); err != nil || !s.Valid || len(s.Val) != 0 { + t.Errorf("empty array: %+v %v", s, err) + } + var i SqlInt32Array + if err := i.Scan("{}"); err != nil || !i.Valid || len(i.Val) != 0 { + t.Errorf("empty int array: %+v %v", i, err) + } + var v SqlVector + if err := v.Scan("[]"); err != nil || !v.Valid || len(v.Val) != 0 { + t.Errorf("empty vector: %+v %v", v, err) + } + if err := v.Scan("[1,x]"); err == nil { + t.Error("bad vector element must fail") + } + if err := v.Scan(42); err == nil { + t.Error("vector Scan(int) must fail") + } + for _, bad := range []string{"not an array", "{unterminated"} { + var a SqlInt32Array + if err := a.Scan(bad); err == nil { + t.Errorf("Scan(%q) must fail", bad) + } + } +} + +func TestArrayJSONIsPlainSlice(t *testing.T) { + b, err := json.Marshal(NewSqlInt32Array([]int32{1, 2})) + if err != nil || string(b) != "[1,2]" { + t.Errorf("got %s, %v", b, err) + } +}