From 33e91c96e87d7e4e6a3a1676f9babd9b9a03b554 Mon Sep 17 00:00:00 2001 From: Keenan Nemetz Date: Mon, 7 Oct 2024 10:11:52 -0700 Subject: [PATCH] feat: JSON type coercion (#3098) ## Relevant issue(s) Resolves #3097 ## Description This PR fixes JSON type coercion and makes it much simpler to filter, create, and update JSON fields. ## Tasks - [x] I made sure the code is well commented, particularly hard-to-understand areas. - [x] I made sure the repository-held documentation is changed accordingly. - [x] I made sure the pull request title adheres to the conventional commit style (the subset used in the project can be found in [tools/configs/chglog/config.yml](tools/configs/chglog/config.yml)). - [x] I made sure to discuss its limitations such as threats to validity, vulnerability to mistake and misuse, robustness to invalidation of assumptions, resource requirements, ... ## How has this been tested? Added / updated unit and integration tests. Specify the platform(s) on which this was tested: - MacOS --- client/document.go | 74 ++++- client/document_test.go | 30 +- client/errors.go | 2 +- client/normal_new.go | 2 + client/normal_scalar.go | 24 ++ client/normal_value.go | 3 + client/normal_value_test.go | 59 ++++ client/normal_void.go | 4 + .../i3097-json-type-coercion.md | 3 + go.mod | 2 +- go.sum | 4 +- internal/connor/connor.go | 73 +++-- internal/connor/eq.go | 26 +- internal/core/encoding.go | 42 +++ internal/planner/mapper/mapper.go | 5 + internal/request/graphql/schema/collection.go | 4 +- .../request/graphql/schema/types/scalars.go | 79 ++--- .../graphql/schema/types/scalars_test.go | 103 ++----- .../field_kinds/field_kind_json_test.go | 287 ++++++++++++++++-- .../mutation/create/with_variables_test.go | 36 +++ .../mutation/update/field_kinds/json_test.go | 2 +- .../simple/with_filter/with_eq_json_test.go | 125 +++++++- .../simple/with_filter/with_in_json_test.go | 25 +- .../updates/add/field/kind/json_test.go | 8 +- 24 files changed, 826 insertions(+), 196 deletions(-) create mode 100644 docs/data_format_changes/i3097-json-type-coercion.md diff --git a/client/document.go b/client/document.go index a49a60307b..cc15d45673 100644 --- a/client/document.go +++ b/client/document.go @@ -341,7 +341,7 @@ func validateFieldSchema(val any, field FieldDefinition) (NormalValue, error) { if err != nil { return nil, err } - return NewNormalString(v), nil + return NewNormalJSON(&JSON{v}), nil } return nil, NewErrUnhandledType("FieldKind", field.Kind) @@ -417,16 +417,70 @@ func getDateTime(v any) (time.Time, error) { return time.Parse(time.RFC3339, s) } -func getJSON(v any) (string, error) { - s, err := getString(v) - if err != nil { - return "", err - } - val, err := fastjson.Parse(s) - if err != nil { - return "", NewErrInvalidJSONPaylaod(s) +// getJSON converts the given value to a valid JSON value. +// +// If the value is of type *fastjson.Value it needs to be +// manually parsed. All other values are valid JSON. +func getJSON(v any) (any, error) { + val, ok := v.(*fastjson.Value) + if !ok { + return v, nil + } + switch val.Type() { + case fastjson.TypeArray: + arr, err := val.Array() + if err != nil { + return nil, err + } + out := make([]any, len(arr)) + for i, v := range arr { + c, err := getJSON(v) + if err != nil { + return nil, err + } + out[i] = c + } + return out, nil + + case fastjson.TypeObject: + obj, err := val.Object() + if err != nil { + return nil, err + } + out := make(map[string]any) + obj.Visit(func(key []byte, v *fastjson.Value) { + c, e := getJSON(v) + out[string(key)] = c + err = errors.Join(err, e) + }) + return out, err + + case fastjson.TypeFalse: + return false, nil + + case fastjson.TypeTrue: + return true, nil + + case fastjson.TypeNumber: + out, err := val.Int64() + if err == nil { + return out, nil + } + return val.Float64() + + case fastjson.TypeString: + out, err := val.StringBytes() + if err != nil { + return nil, err + } + return string(out), nil + + case fastjson.TypeNull: + return nil, nil + + default: + return nil, NewErrInvalidJSONPayload(v) } - return val.String(), nil } func getArray[T any]( diff --git a/client/document_test.go b/client/document_test.go index a11a6a67c8..b74af54b27 100644 --- a/client/document_test.go +++ b/client/document_test.go @@ -161,7 +161,16 @@ func TestNewFromJSON_WithValidJSONFieldValue_NoError(t *testing.T) { objWithJSONField := []byte(`{ "Name": "John", "Age": 26, - "Custom": "{\"tree\":\"maple\", \"age\": 260}" + "Custom": { + "string": "maple", + "int": 260, + "float": 3.14, + "false": false, + "true": true, + "null": null, + "array": ["one", 1], + "object": {"one": 1} + } }`) doc, err := NewDocFromJSON(objWithJSONField, def) if err != nil { @@ -183,7 +192,16 @@ func TestNewFromJSON_WithValidJSONFieldValue_NoError(t *testing.T) { assert.Equal(t, doc.values[doc.fields["Name"]].IsDocument(), false) assert.Equal(t, doc.values[doc.fields["Age"]].Value(), int64(26)) assert.Equal(t, doc.values[doc.fields["Age"]].IsDocument(), false) - assert.Equal(t, doc.values[doc.fields["Custom"]].Value(), "{\"tree\":\"maple\",\"age\":260}") + assert.Equal(t, doc.values[doc.fields["Custom"]].Value(), map[string]any{ + "string": "maple", + "int": int64(260), + "float": float64(3.14), + "false": false, + "true": true, + "null": nil, + "array": []any{"one", int64(1)}, + "object": map[string]any{"one": int64(1)}, + }) assert.Equal(t, doc.values[doc.fields["Custom"]].IsDocument(), false) } @@ -191,20 +209,20 @@ func TestNewFromJSON_WithInvalidJSONFieldValue_Error(t *testing.T) { objWithJSONField := []byte(`{ "Name": "John", "Age": 26, - "Custom": "{\"tree\":\"maple, \"age\": 260}" + "Custom": {"tree":"maple, "age": 260} }`) _, err := NewDocFromJSON(objWithJSONField, def) - require.ErrorContains(t, err, "invalid JSON payload. Payload: {\"tree\":\"maple, \"age\": 260}") + require.ErrorContains(t, err, "cannot parse JSON") } -func TestNewFromJSON_WithInvalidJSONFieldValueSimpleString_Error(t *testing.T) { +func TestNewFromJSON_WithJSONFieldValueSimpleString_Succeed(t *testing.T) { objWithJSONField := []byte(`{ "Name": "John", "Age": 26, "Custom": "blah" }`) _, err := NewDocFromJSON(objWithJSONField, def) - require.ErrorContains(t, err, "invalid JSON payload. Payload: blah") + require.NoError(t, err) } func TestIsJSONArray(t *testing.T) { diff --git a/client/errors.go b/client/errors.go index ceb526b35e..81ebf2e3f5 100644 --- a/client/errors.go +++ b/client/errors.go @@ -166,7 +166,7 @@ func NewErrCRDTKindMismatch(cType, kind string) error { return errors.New(fmt.Sprintf(errCRDTKindMismatch, cType, kind)) } -func NewErrInvalidJSONPaylaod(payload string) error { +func NewErrInvalidJSONPayload(payload any) error { return errors.New(errInvalidJSONPayload, errors.NewKV("Payload", payload)) } diff --git a/client/normal_new.go b/client/normal_new.go index 55ac46ce73..bcd0f00929 100644 --- a/client/normal_new.go +++ b/client/normal_new.go @@ -64,6 +64,8 @@ func NewNormalValue(val any) (NormalValue, error) { return NewNormalTime(v), nil case *Document: return NewNormalDocument(v), nil + case *JSON: + return NewNormalJSON(v), nil case immutable.Option[bool]: return NewNormalNillableBool(v), nil diff --git a/client/normal_scalar.go b/client/normal_scalar.go index f30eca78d7..ae92fbe3a6 100644 --- a/client/normal_scalar.go +++ b/client/normal_scalar.go @@ -17,6 +17,13 @@ import ( "golang.org/x/exp/constraints" ) +// JSON contains a valid JSON value. +// +// The inner type can be any valid normal value or normal value array. +type JSON struct { + inner any +} + // NormalValue is dummy implementation of NormalValue to be embedded in other types. type baseNormalValue[T any] struct { NormalVoid @@ -118,6 +125,18 @@ func (v normalDocument) Document() (*Document, bool) { return v.val, true } +type normalJSON struct { + baseNormalValue[*JSON] +} + +func (v normalJSON) JSON() (*JSON, bool) { + return v.val, true +} + +func (v normalJSON) Unwrap() any { + return v.val.inner +} + func newNormalInt(val int64) NormalValue { return normalInt{newBaseNormalValue(val)} } @@ -161,6 +180,11 @@ func NewNormalDocument(val *Document) NormalValue { return normalDocument{baseNormalValue[*Document]{val: val}} } +// NewNormalJSON creates a new NormalValue that represents a `JSON` value. +func NewNormalJSON(val *JSON) NormalValue { + return normalJSON{baseNormalValue[*JSON]{val: val}} +} + func areNormalScalarsEqual[T comparable](val T, f func() (T, bool)) bool { if otherVal, ok := f(); ok { return val == otherVal diff --git a/client/normal_value.go b/client/normal_value.go index 18bdd74ff0..081814ffe2 100644 --- a/client/normal_value.go +++ b/client/normal_value.go @@ -62,6 +62,9 @@ type NormalValue interface { // Document returns the value as a [*Document]. The second return flag is true if the value is a [*Document]. // Otherwise it will return nil and false. Document() (*Document, bool) + // JSON returns the value as JSON. The second return flag is true if the value is JSON. + // Otherwise it will return nil and false. + JSON() (*JSON, bool) // NillableBool returns the value as a nillable bool. // The second return flag is true if the value is [immutable.Option[bool]]. diff --git a/client/normal_value_test.go b/client/normal_value_test.go index c368a300e3..bcea59e046 100644 --- a/client/normal_value_test.go +++ b/client/normal_value_test.go @@ -30,6 +30,7 @@ const ( BytesType nType = "Bytes" TimeType nType = "Time" DocumentType nType = "Document" + JSONType nType = "JSON" NillableBoolType nType = "NillableBool" NillableIntType nType = "NillableInt" @@ -76,6 +77,11 @@ const ( // If it is and contains a value, it returns the contained value. // Otherwise, it returns the input itself. func extractValue(input any) any { + // unwrap JSON inner values + if v, ok := input.(*JSON); ok { + return v.inner + } + inputVal := reflect.ValueOf(input) // Check if the type is Option[T] by seeing if it has the HasValue and Value methods. @@ -112,6 +118,7 @@ func TestNormalValue_NewValueAndTypeAssertion(t *testing.T) { BytesType: func(v NormalValue) (any, bool) { return v.Bytes() }, TimeType: func(v NormalValue) (any, bool) { return v.Time() }, DocumentType: func(v NormalValue) (any, bool) { return v.Document() }, + JSONType: func(v NormalValue) (any, bool) { return v.JSON() }, NillableBoolType: func(v NormalValue) (any, bool) { return v.NillableBool() }, NillableIntType: func(v NormalValue) (any, bool) { return v.NillableInt() }, @@ -164,6 +171,7 @@ func TestNormalValue_NewValueAndTypeAssertion(t *testing.T) { BytesType: func(v any) NormalValue { return NewNormalBytes(v.([]byte)) }, TimeType: func(v any) NormalValue { return NewNormalTime(v.(time.Time)) }, DocumentType: func(v any) NormalValue { return NewNormalDocument(v.(*Document)) }, + JSONType: func(v any) NormalValue { return NewNormalJSON(v.(*JSON)) }, NillableBoolType: func(v any) NormalValue { return NewNormalNillableBool(v.(immutable.Option[bool])) }, NillableIntType: func(v any) NormalValue { return NewNormalNillableInt(v.(immutable.Option[int64])) }, @@ -283,6 +291,10 @@ func TestNormalValue_NewValueAndTypeAssertion(t *testing.T) { nType: DocumentType, input: &Document{}, }, + { + nType: JSONType, + input: &JSON{nil}, + }, { nType: NillableBoolType, input: immutable.Some(true), @@ -830,6 +842,53 @@ func TestNormalValue_NewNormalValueFromAnyArray(t *testing.T) { } } +func TestNormalValue_NewNormalJSON(t *testing.T) { + var expect *JSON + var actual *JSON + + expect = &JSON{nil} + normal := NewNormalJSON(expect) + + actual, _ = normal.JSON() + assert.Equal(t, expect, actual) + + expect = &JSON{"hello"} + normal = NewNormalJSON(expect) + + actual, _ = normal.JSON() + assert.Equal(t, expect, actual) + + expect = &JSON{true} + normal = NewNormalJSON(expect) + + actual, _ = normal.JSON() + assert.Equal(t, expect, actual) + + expect = &JSON{int64(10)} + normal = NewNormalJSON(expect) + + actual, _ = normal.JSON() + assert.Equal(t, expect, actual) + + expect = &JSON{float64(3.14)} + normal = NewNormalJSON(expect) + + actual, _ = normal.JSON() + assert.Equal(t, expect, actual) + + expect = &JSON{map[string]any{"one": 1}} + normal = NewNormalJSON(expect) + + actual, _ = normal.JSON() + assert.Equal(t, expect, actual) + + expect = &JSON{[]any{1, "two"}} + normal = NewNormalJSON(expect) + + actual, _ = normal.JSON() + assert.Equal(t, expect, actual) +} + func TestNormalValue_NewNormalInt(t *testing.T) { i64 := int64(2) v := NewNormalInt(i64) diff --git a/client/normal_void.go b/client/normal_void.go index 3e13fe489d..3238a25ad2 100644 --- a/client/normal_void.go +++ b/client/normal_void.go @@ -65,6 +65,10 @@ func (NormalVoid) Document() (*Document, bool) { return nil, false } +func (NormalVoid) JSON() (*JSON, bool) { + return nil, false +} + func (NormalVoid) NillableBool() (immutable.Option[bool], bool) { return immutable.None[bool](), false } diff --git a/docs/data_format_changes/i3097-json-type-coercion.md b/docs/data_format_changes/i3097-json-type-coercion.md new file mode 100644 index 0000000000..f1978c4e57 --- /dev/null +++ b/docs/data_format_changes/i3097-json-type-coercion.md @@ -0,0 +1,3 @@ +# JSON type coercion + +JSON types are now stored as parsed values instead of strings. \ No newline at end of file diff --git a/go.mod b/go.mod index 43acdd72ca..708e2889c8 100644 --- a/go.mod +++ b/go.mod @@ -48,7 +48,7 @@ require ( github.com/sourcenetwork/badger/v4 v4.2.1-0.20231113215945-a63444ca5276 github.com/sourcenetwork/corelog v0.0.8 github.com/sourcenetwork/go-libp2p-pubsub-rpc v0.0.14 - github.com/sourcenetwork/graphql-go v0.7.10-0.20240924172903-a4088313b20d + github.com/sourcenetwork/graphql-go v0.7.10-0.20241003221550-224346887b4a github.com/sourcenetwork/immutable v0.3.0 github.com/sourcenetwork/sourcehub v0.2.1-0.20240704194128-f43f5e427274 github.com/spf13/cobra v1.8.1 diff --git a/go.sum b/go.sum index 83aa313637..6151cbdb1e 100644 --- a/go.sum +++ b/go.sum @@ -1398,8 +1398,8 @@ github.com/sourcenetwork/corelog v0.0.8 h1:jCo0mFBpWrfhUCGzzN3uUtPGyQv3jnITdPO1s github.com/sourcenetwork/corelog v0.0.8/go.mod h1:cMabHgs3kARgYTQeQYSOmaGGP8XMU6sZrHd8LFrL3zA= github.com/sourcenetwork/go-libp2p-pubsub-rpc v0.0.14 h1:620zKV4rOn7U5j/WsPkk4SFj0z9/pVV4bBx0BpZQgro= github.com/sourcenetwork/go-libp2p-pubsub-rpc v0.0.14/go.mod h1:jUoQv592uUX1u7QBjAY4C+l24X9ArhPfifOqXpDHz4U= -github.com/sourcenetwork/graphql-go v0.7.10-0.20240924172903-a4088313b20d h1:P5y4g1ONf8HK36L86/8zDYjY7rRLM7AaqlQDRHOBMH8= -github.com/sourcenetwork/graphql-go v0.7.10-0.20240924172903-a4088313b20d/go.mod h1:rkahXkgRH/3vZErN1Bx+qt1+w+CV5fgaJyKKWgISe4U= +github.com/sourcenetwork/graphql-go v0.7.10-0.20241003221550-224346887b4a h1:wF7VhX0XKuNqhbbDI7RjoIBKFvdKQKucpZlNr/BGE40= +github.com/sourcenetwork/graphql-go v0.7.10-0.20241003221550-224346887b4a/go.mod h1:rkahXkgRH/3vZErN1Bx+qt1+w+CV5fgaJyKKWgISe4U= github.com/sourcenetwork/immutable v0.3.0 h1:gHPtGvLrTBTK5YpDAhMU+u+S8v1F6iYmc3nbZLryMdc= github.com/sourcenetwork/immutable v0.3.0/go.mod h1:GD7ceuh/HD7z6cdIwzKK2ctzgZ1qqYFJpsFp+8qYnbI= github.com/sourcenetwork/raccoondb v0.2.1-0.20240606193653-1e91e9be9234 h1:8dA9bVC1A0ChJygtsUfNsek3oR0GnwpLoYpmEo4t2mk= diff --git a/internal/connor/connor.go b/internal/connor/connor.go index 086ba0cd49..a7a8290dbe 100644 --- a/internal/connor/connor.go +++ b/internal/connor/connor.go @@ -5,6 +5,43 @@ It is derived from https://github.com/SierraSoftworks/connor. */ package connor +const ( + AndOp = "_and" + OrOp = "_or" + NotOp = "_not" + + AnyOp = "_any" + AllOp = "_all" + NoneOp = "_none" + + EqualOp = "_eq" + GreaterOrEqualOp = "_ge" + GreaterOp = "_gt" + InOp = "_in" + LesserOrEqualOp = "_le" + LesserOp = "_lt" + NotEqualOp = "_ne" + NotInOp = "_nin" + LikeOp = "_like" + NotLikeOp = "_nlike" + CaseInsensitiveLikeOp = "_ilike" + CaseInsensitiveNotLikeOp = "_nilike" +) + +// IsOpSimple returns true if the given operator is simple (not compound). +// +// This is useful for checking if a filter operator requires further expansion. +func IsOpSimple(op string) bool { + switch op { + case EqualOp, GreaterOrEqualOp, GreaterOp, InOp, + LesserOrEqualOp, LesserOp, NotEqualOp, NotInOp, + LikeOp, NotLikeOp, CaseInsensitiveLikeOp, CaseInsensitiveNotLikeOp: + return true + default: + return false + } +} + // Match is the default method used in Connor to match some data to a // set of conditions. func Match(conditions map[FilterKey]any, data any) (bool, error) { @@ -16,41 +53,41 @@ func Match(conditions map[FilterKey]any, data any) (bool, error) { // if you wish to override the behavior of another operator. func matchWith(op string, conditions, data any) (bool, error) { switch op { - case "_and": + case AndOp: return and(conditions, data) - case "_any": + case AnyOp: return anyOp(conditions, data) - case "_all": + case AllOp: return all(conditions, data) - case "_eq": + case EqualOp: return eq(conditions, data) - case "_ge": + case GreaterOrEqualOp: return ge(conditions, data) - case "_gt": + case GreaterOp: return gt(conditions, data) - case "_in": + case InOp: return in(conditions, data) - case "_le": + case LesserOrEqualOp: return le(conditions, data) - case "_lt": + case LesserOp: return lt(conditions, data) - case "_ne": + case NotEqualOp: return ne(conditions, data) - case "_nin": + case NotInOp: return nin(conditions, data) - case "_or": + case OrOp: return or(conditions, data) - case "_like": + case LikeOp: return like(conditions, data) - case "_nlike": + case NotLikeOp: return nlike(conditions, data) - case "_ilike": + case CaseInsensitiveLikeOp: return ilike(conditions, data) - case "_nilike": + case CaseInsensitiveNotLikeOp: return nilike(conditions, data) - case "_none": + case NoneOp: return none(conditions, data) - case "_not": + case NotOp: return not(conditions, data) default: return false, NewErrUnknownOperator(op) diff --git a/internal/connor/eq.go b/internal/connor/eq.go index 3f849348b8..6b9f56293a 100644 --- a/internal/connor/eq.go +++ b/internal/connor/eq.go @@ -34,7 +34,7 @@ func eq(condition, data any) (bool, error) { switch cn := condition.(type) { case map[FilterKey]any: for prop, cond := range cn { - m, err := matchWith(prop.GetOperatorOrDefault("_eq"), cond, prop.GetProp(data)) + m, err := matchWith(prop.GetOperatorOrDefault(EqualOp), cond, prop.GetProp(data)) if err != nil { return false, err } else if !m { @@ -43,6 +43,9 @@ func eq(condition, data any) (bool, error) { } return true, nil + case map[string]any: + return objectsEqual(cn, data) + case string: if d, ok := data.(string); ok { return d == cn, nil @@ -66,6 +69,27 @@ func eq(condition, data any) (bool, error) { } } +// objectsEqual returns true if the given condition and data +// contain equal key value pairs. +func objectsEqual(condition map[string]any, data any) (bool, error) { + if data == nil { + return condition == nil, nil + } + d := data.(map[string]any) + if len(d) != len(condition) { + return false, nil + } + for k, v := range d { + m, err := eq(condition[k], v) + if err != nil { + return false, err + } else if !m { + return false, nil + } + } + return true, nil +} + func immutableValueOrNil[T any](data immutable.Option[T]) any { if data.HasValue() { return data.Value() diff --git a/internal/core/encoding.go b/internal/core/encoding.go index 6e2e0e0dcb..8c7930d6b9 100644 --- a/internal/core/encoding.go +++ b/internal/core/encoding.go @@ -94,6 +94,9 @@ func NormalizeFieldValue(fieldDesc client.FieldDefinition, val any) (any, error) if err != nil { return nil, err } + + case client.FieldKind_NILLABLE_JSON: + return convertToJSON(fieldDesc.Name, val) } } else { // CBOR often encodes values typed as floats as ints switch fieldDesc.Kind { @@ -136,6 +139,8 @@ func NormalizeFieldValue(fieldDesc client.FieldDefinition, val any) (any, error) case []byte: return string(v), nil } + case client.FieldKind_NILLABLE_JSON: + return convertToJSON(fieldDesc.Name, val) } } @@ -191,6 +196,43 @@ func convertToInt(propertyName string, untypedValue any) (int64, error) { } } +// convertToJSON converts the given value to a valid JSON value. +// +// When maps are decoded, they are of type map[any]any, and need to +// be converted to map[string]any. All other values are valid JSON. +func convertToJSON(propertyName string, untypedValue any) (any, error) { + switch t := untypedValue.(type) { + case map[any]any: + resultValue := make(map[string]any) + for k, v := range t { + key, ok := k.(string) + if !ok { + return nil, client.NewErrUnexpectedType[string](propertyName, k) + } + val, err := convertToJSON(fmt.Sprintf("%s.%s", propertyName, key), v) + if err != nil { + return nil, err + } + resultValue[key] = val + } + return resultValue, nil + + case []any: + resultValue := make([]any, len(t)) + for i, v := range t { + val, err := convertToJSON(fmt.Sprintf("%s[%d]", propertyName, i), v) + if err != nil { + return nil, err + } + resultValue[i] = val + } + return resultValue, nil + + default: + return untypedValue, nil + } +} + // DecodeIndexDataStoreKey decodes a IndexDataStoreKey from bytes. // It expects the input bytes is in the following format: // diff --git a/internal/planner/mapper/mapper.go b/internal/planner/mapper/mapper.go index dfadd2f06c..da8390e293 100644 --- a/internal/planner/mapper/mapper.go +++ b/internal/planner/mapper/mapper.go @@ -1309,6 +1309,11 @@ func toFilterMap( key := &Operator{ Operation: sourceKey, } + // if the operator is simple (not compound) then + // it does not require further expansion + if connor.IsOpSimple(sourceKey) { + return key, sourceClause + } switch typedClause := sourceClause.(type) { case []any: // If the clause is an array then we need to convert any inner maps. diff --git a/internal/request/graphql/schema/collection.go b/internal/request/graphql/schema/collection.go index 5c196f1f0f..81d5182366 100644 --- a/internal/request/graphql/schema/collection.go +++ b/internal/request/graphql/schema/collection.go @@ -434,9 +434,9 @@ func defaultFromAST( } switch t := arg.Value.(type) { case *ast.IntValue: - value = gql.Int.ParseLiteral(arg.Value) + value = gql.Int.ParseLiteral(arg.Value, nil) case *ast.FloatValue: - value = gql.Float.ParseLiteral(arg.Value) + value = gql.Float.ParseLiteral(arg.Value, nil) case *ast.BooleanValue: value = t.Value case *ast.StringValue: diff --git a/internal/request/graphql/schema/types/scalars.go b/internal/request/graphql/schema/types/scalars.go index b86c744607..20fd3d13b6 100644 --- a/internal/request/graphql/schema/types/scalars.go +++ b/internal/request/graphql/schema/types/scalars.go @@ -16,7 +16,6 @@ import ( "github.com/sourcenetwork/graphql-go" "github.com/sourcenetwork/graphql-go/language/ast" - "github.com/valyala/fastjson" ) // BlobPattern is a regex for validating blob hex strings @@ -55,7 +54,7 @@ func BlobScalarType() *graphql.Scalar { // ParseValue converts the value to a hex string ParseValue: coerceBlob, // ParseLiteral converts the ast value to a hex string - ParseLiteral: func(valueAST ast.Value) any { + ParseLiteral: func(valueAST ast.Value, variables map[string]any) any { switch valueAST := valueAST.(type) { case *ast.StringValue: return coerceBlob(valueAST.Value) @@ -67,33 +66,39 @@ func BlobScalarType() *graphql.Scalar { }) } -// coerceJSON converts the given value into a valid json string. -// If the value cannot be converted nil is returned. -func coerceJSON(value any) any { - switch value := value.(type) { - case []byte: - err := fastjson.ValidateBytes(value) - if err != nil { - // ignore this error because the value - // cannot be converted to a json string - return nil +func parseJSON(valueAST ast.Value, variables map[string]any) any { + switch valueAST := valueAST.(type) { + case *ast.ObjectValue: + out := make(map[string]any) + for _, f := range valueAST.Fields { + out[f.Name.Value] = parseJSON(f.Value, variables) } - return string(value) - - case *[]byte: - return coerceJSON(*value) + return out - case string: - err := fastjson.Validate(value) - if err != nil { - // ignore this error because the value - // cannot be converted to a json string - return nil + case *ast.ListValue: + out := make([]any, len(valueAST.Values)) + for i, v := range valueAST.Values { + out[i] = parseJSON(v, variables) } - return value + return out - case *string: - return coerceJSON(*value) + case *ast.BooleanValue: + return graphql.Boolean.ParseLiteral(valueAST, variables) + + case *ast.FloatValue: + return graphql.Float.ParseLiteral(valueAST, variables) + + case *ast.IntValue: + return graphql.Int.ParseLiteral(valueAST, variables) + + case *ast.StringValue: + return graphql.String.ParseLiteral(valueAST, variables) + + case *ast.EnumValue: + return valueAST.Value + + case *ast.Variable: + return variables[valueAST.Name.Value] default: return nil @@ -103,20 +108,16 @@ func coerceJSON(value any) any { func JSONScalarType() *graphql.Scalar { return graphql.NewScalar(graphql.ScalarConfig{ Name: "JSON", - Description: "The `JSON` scalar type represents a JSON string.", - // Serialize converts the value to a json string - Serialize: coerceJSON, - // ParseValue converts the value to a json string - ParseValue: coerceJSON, - // ParseLiteral converts the ast value to a json string - ParseLiteral: func(valueAST ast.Value) any { - switch valueAST := valueAST.(type) { - case *ast.StringValue: - return coerceJSON(valueAST.Value) - default: - // return nil if the value cannot be parsed - return nil - } + Description: "The `JSON` scalar type represents a JSON value.", + // Serialize converts the value to json value + Serialize: func(value any) any { + return value + }, + // ParseValue converts the value to json value + ParseValue: func(value any) any { + return value }, + // ParseLiteral converts the ast value to a json value + ParseLiteral: parseJSON, }) } diff --git a/internal/request/graphql/schema/types/scalars_test.go b/internal/request/graphql/schema/types/scalars_test.go index fba94ce67b..a0b977aee0 100644 --- a/internal/request/graphql/schema/types/scalars_test.go +++ b/internal/request/graphql/schema/types/scalars_test.go @@ -82,90 +82,49 @@ func TestBlobScalarTypeParseLiteral(t *testing.T) { {&ast.ObjectValue{}, nil}, } for _, c := range cases { - result := BlobScalarType().ParseLiteral(c.input) + result := BlobScalarType().ParseLiteral(c.input, nil) assert.Equal(t, c.expect, result) } } -func TestJSONScalarTypeParseAndSerialize(t *testing.T) { - validString := `"hello"` - validBytes := []byte(`"hello"`) - - boolString := "true" - boolBytes := []byte("true") - - intString := "0" - intBytes := []byte("0") - - floatString := "3.14" - floatBytes := []byte("3.14") - - objectString := `{"name": "Bob"}` - objectBytes := []byte(`{"name": "Bob"}`) - - invalidString := "invalid" - invalidBytes := []byte("invalid") - - cases := []struct { - input any - expect any - }{ - {validString, `"hello"`}, - {&validString, `"hello"`}, - {validBytes, `"hello"`}, - {&validBytes, `"hello"`}, - {boolString, "true"}, - {&boolString, "true"}, - {boolBytes, "true"}, - {&boolBytes, "true"}, - {[]byte("true"), "true"}, - {[]byte("false"), "false"}, - {intString, "0"}, - {&intString, "0"}, - {intBytes, "0"}, - {&intBytes, "0"}, - {floatString, "3.14"}, - {&floatString, "3.14"}, - {floatBytes, "3.14"}, - {&floatBytes, "3.14"}, - {invalidString, nil}, - {&invalidString, nil}, - {invalidBytes, nil}, - {&invalidBytes, nil}, - {objectString, `{"name": "Bob"}`}, - {&objectString, `{"name": "Bob"}`}, - {objectBytes, `{"name": "Bob"}`}, - {&objectBytes, `{"name": "Bob"}`}, - {nil, nil}, - {0, nil}, - {false, nil}, - } - for _, c := range cases { - parsed := JSONScalarType().ParseValue(c.input) - assert.Equal(t, c.expect, parsed) - - serialized := JSONScalarType().Serialize(c.input) - assert.Equal(t, c.expect, serialized) - } -} - func TestJSONScalarTypeParseLiteral(t *testing.T) { cases := []struct { input ast.Value expect any }{ - {&ast.StringValue{Value: "0"}, "0"}, - {&ast.StringValue{Value: "invalid"}, nil}, - {&ast.IntValue{}, nil}, - {&ast.BooleanValue{}, nil}, + {&ast.StringValue{Value: "hello"}, "hello"}, + {&ast.IntValue{Value: "10"}, int32(10)}, + {&ast.BooleanValue{Value: true}, true}, {&ast.NullValue{}, nil}, - {&ast.EnumValue{}, nil}, - {&ast.FloatValue{}, nil}, - {&ast.ListValue{}, nil}, - {&ast.ObjectValue{}, nil}, + {&ast.EnumValue{Value: "DESC"}, "DESC"}, + {&ast.Variable{Name: &ast.Name{Value: "message"}}, "hello"}, + {&ast.Variable{Name: &ast.Name{Value: "invalid"}}, nil}, + {&ast.FloatValue{Value: "3.14"}, 3.14}, + {&ast.ListValue{Values: []ast.Value{ + &ast.StringValue{Value: "hello"}, + &ast.IntValue{Value: "10"}, + }}, []any{"hello", int32(10)}}, + {&ast.ObjectValue{ + Fields: []*ast.ObjectField{ + { + Name: &ast.Name{Value: "int"}, + Value: &ast.IntValue{Value: "10"}, + }, + { + Name: &ast.Name{Value: "string"}, + Value: &ast.StringValue{Value: "hello"}, + }, + }, + }, map[string]any{ + "int": int32(10), + "string": "hello", + }}, + } + variables := map[string]any{ + "message": "hello", } for _, c := range cases { - result := JSONScalarType().ParseLiteral(c.input) + result := JSONScalarType().ParseLiteral(c.input, variables) assert.Equal(t, c.expect, result) } } diff --git a/tests/integration/mutation/create/field_kinds/field_kind_json_test.go b/tests/integration/mutation/create/field_kinds/field_kind_json_test.go index cc97bd162f..b578bf3928 100644 --- a/tests/integration/mutation/create/field_kinds/field_kind_json_test.go +++ b/tests/integration/mutation/create/field_kinds/field_kind_json_test.go @@ -14,11 +14,13 @@ import ( "testing" testUtils "github.com/sourcenetwork/defradb/tests/integration" + + "github.com/sourcenetwork/immutable" ) -func TestMutationCreate_WithJSONFieldGivenValidJSON_NoError(t *testing.T) { +func TestMutationCreate_WithJSONFieldGivenObjectValue_Succeeds(t *testing.T) { test := testUtils.TestCase{ - Description: "Create mutation with JSON field given a valid JSON string.", + Description: "Create mutation with JSON field given an object value.", Actions: []any{ testUtils.SchemaUpdate{ Schema: ` @@ -28,19 +30,70 @@ func TestMutationCreate_WithJSONFieldGivenValidJSON_NoError(t *testing.T) { } `, }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": {"tree": "maple", "age": 250} + }`, + }, testUtils.Request{ - Request: `mutation { - create_Users(input: {name: "John", custom: "{\"tree\": \"maple\", \"age\": 250}"}) { + Request: `query { + Users { _docID name custom } }`, Results: map[string]any{ - "create_Users": []map[string]any{ + "Users": []map[string]any{ { - "_docID": "bae-84ae4ef8-ca0c-5f32-bc85-cee97e731bc0", - "custom": "{\"tree\":\"maple\",\"age\":250}", + "_docID": "bae-a948a3b2-3e89-5654-b0f0-71685a66b4d7", + "custom": map[string]any{ + "tree": "maple", + "age": uint64(250), + }, + "name": "John", + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestMutationCreate_WithJSONFieldGivenListOfScalarsValue_Succeeds(t *testing.T) { + test := testUtils.TestCase{ + Description: "Create mutation with JSON field given a list of scalars value.", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + custom: JSON + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": ["maple", 250] + }`, + }, + testUtils.Request{ + Request: `query { + Users { + _docID + name + custom + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "_docID": "bae-90fd8b1b-bd11-56b5-a78c-2fb6f7b4dca0", + "custom": []any{"maple", uint64(250)}, "name": "John", }, }, @@ -52,9 +105,9 @@ func TestMutationCreate_WithJSONFieldGivenValidJSON_NoError(t *testing.T) { testUtils.ExecuteTestCase(t, test) } -func TestMutationCreate_WithJSONFieldGivenInvalidJSON_Error(t *testing.T) { +func TestMutationCreate_WithJSONFieldGivenListOfObjectsValue_Succeeds(t *testing.T) { test := testUtils.TestCase{ - Description: "Create mutation with JSON field given a valid JSON string.", + Description: "Create mutation with JSON field given a list of objects value.", Actions: []any{ testUtils.SchemaUpdate{ Schema: ` @@ -64,16 +117,35 @@ func TestMutationCreate_WithJSONFieldGivenInvalidJSON_Error(t *testing.T) { } `, }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": [ + {"tree": "maple"}, + {"tree": "oak"} + ] + }`, + }, testUtils.Request{ - Request: `mutation { - create_Users(input: {name: "John", custom: "{\"tree\": \"maple, \"age\": 250}"}) { + Request: `query { + Users { _docID name custom } }`, - ExpectedError: `Argument "input" has invalid value {name: "John", custom: "{\"tree\": \"maple, \"age\": 250}"}. -In field "custom": Expected type "JSON", found "{\"tree\": \"maple, \"age\": 250}".`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "_docID": "bae-dd7c12f5-a7c5-55c6-8b35-ece853ae7f9e", + "custom": []any{ + map[string]any{"tree": "maple"}, + map[string]any{"tree": "oak"}, + }, + "name": "John", + }, + }, + }, }, }, } @@ -81,9 +153,9 @@ In field "custom": Expected type "JSON", found "{\"tree\": \"maple, \"age\": 250 testUtils.ExecuteTestCase(t, test) } -func TestMutationCreate_WithJSONFieldGivenSimpleString_Error(t *testing.T) { +func TestMutationCreate_WithJSONFieldGivenIntValue_Succeeds(t *testing.T) { test := testUtils.TestCase{ - Description: "Create mutation with JSON field given a valid JSON string.", + Description: "Create mutation with JSON field given a int value.", Actions: []any{ testUtils.SchemaUpdate{ Schema: ` @@ -93,16 +165,193 @@ func TestMutationCreate_WithJSONFieldGivenSimpleString_Error(t *testing.T) { } `, }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": 250 + }`, + }, + testUtils.Request{ + Request: `query { + Users { + _docID + name + custom + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "_docID": "bae-59731737-8793-5794-a9a5-0ed0ad696d5c", + "custom": uint64(250), + "name": "John", + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestMutationCreate_WithJSONFieldGivenStringValue_Succeeds(t *testing.T) { + test := testUtils.TestCase{ + Description: "Create mutation with JSON field given a string value.", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + custom: JSON + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": "hello" + }`, + }, testUtils.Request{ - Request: `mutation { - create_Users(input: {name: "John", custom: "blah"}) { + Request: `query { + Users { _docID name custom } }`, - ExpectedError: `Argument "input" has invalid value {name: "John", custom: "blah"}. -In field "custom": Expected type "JSON", found "blah".`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "_docID": "bae-608582c3-979e-5f34-80f8-a70fce875d05", + "custom": "hello", + "name": "John", + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestMutationCreate_WithJSONFieldGivenBooleanValue_Succeeds(t *testing.T) { + test := testUtils.TestCase{ + Description: "Create mutation with JSON field given a boolean value.", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + custom: JSON + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": true + }`, + }, + testUtils.Request{ + Request: `query { + Users { + _docID + name + custom + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "_docID": "bae-0c4b39cf-433c-5a9c-9bed-1e2796c35d14", + "custom": true, + "name": "John", + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestMutationCreate_WithJSONFieldGivenNullValue_Succeeds(t *testing.T) { + test := testUtils.TestCase{ + Description: "Create mutation with JSON field given a null value.", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + custom: JSON + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": null + }`, + }, + testUtils.Request{ + Request: `query { + Users { + _docID + name + custom + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "_docID": "bae-f405f600-56d9-5de4-8d02-75fdced35e3b", + "custom": nil, + "name": "John", + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +// This test confirms that our JSON value encoding is determinstic. +func TestMutationCreate_WithDuplicateJSONField_ReturnsError(t *testing.T) { + test := testUtils.TestCase{ + Description: "Create mutation with duplicate JSON field errors.", + SupportedMutationTypes: immutable.Some([]testUtils.MutationType{ + // Save will not produce an error on duplicate + // because it will just update the previous doc + testUtils.GQLRequestMutationType, + testUtils.CollectionNamedMutationType, + }), + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + custom: JSON + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": {"one": 1, "two": 2, "three": [0, 1, 2]} + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": {"three": [0, 1, 2], "two": 2, "one": 1} + }`, + ExpectedError: `a document with the given ID already exists`, }, }, } diff --git a/tests/integration/mutation/create/with_variables_test.go b/tests/integration/mutation/create/with_variables_test.go index 5bd4814b16..84b75f6407 100644 --- a/tests/integration/mutation/create/with_variables_test.go +++ b/tests/integration/mutation/create/with_variables_test.go @@ -84,3 +84,39 @@ func TestMutationCreateWithDefaultVariable(t *testing.T) { testUtils.ExecuteTestCase(t, test) } + +func TestMutationCreate_WithJSONVariable_Succeeds(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple create mutation with JSON variable input.", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + embed: JSON + } + `, + }, + testUtils.Request{ + Variables: immutable.Some(map[string]any{ + "message": "hello", + }), + Request: `mutation($message: String) { + create_Users(input: {embed: {message: $message}}) { + embed + } + }`, + Results: map[string]any{ + "create_Users": []map[string]any{ + { + "embed": map[string]any{ + "message": "hello", + }, + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/mutation/update/field_kinds/json_test.go b/tests/integration/mutation/update/field_kinds/json_test.go index 868e0c5a7d..fcf32948fe 100644 --- a/tests/integration/mutation/update/field_kinds/json_test.go +++ b/tests/integration/mutation/update/field_kinds/json_test.go @@ -29,7 +29,7 @@ func TestMutationUpdate_IfJSONFieldSetToNull_ShouldBeNil(t *testing.T) { }, testUtils.CreateDoc{ Doc: `{ - "custom": "{\"foo\": \"bar\"}" + "custom": {"foo": "bar"} }`, }, testUtils.UpdateDoc{ diff --git a/tests/integration/query/simple/with_filter/with_eq_json_test.go b/tests/integration/query/simple/with_filter/with_eq_json_test.go index da0c03ed4b..3f85a033cb 100644 --- a/tests/integration/query/simple/with_filter/with_eq_json_test.go +++ b/tests/integration/query/simple/with_filter/with_eq_json_test.go @@ -16,7 +16,7 @@ import ( testUtils "github.com/sourcenetwork/defradb/tests/integration" ) -func TestQuerySimple_WithEqOpOnJSONField_ShouldFilter(t *testing.T) { +func TestQuerySimple_WithEqOpOnJSONFieldWithObject_ShouldFilter(t *testing.T) { test := testUtils.TestCase{ Actions: []any{ testUtils.SchemaUpdate{ @@ -28,27 +28,132 @@ func TestQuerySimple_WithEqOpOnJSONField_ShouldFilter(t *testing.T) { `, }, testUtils.CreateDoc{ - DocMap: map[string]any{ - "name": "John", - "custom": "{\"tree\": \"maple\", \"age\": 250}", + Doc: `{ + "name": "John", + "custom": { + "tree": "maple", + "age": 250 + } + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "Andy", + "custom": { + "tree": "oak", + "age": 450 + } + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "Shahzad", + "custom": null + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: {custom: {_eq: {tree:"oak",age:450}}}) { + name + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + {"name": "Andy"}, + }, }, }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestQuerySimple_WithEqOpOnJSONFieldWithNestedObjects_ShouldFilter(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + custom: JSON + } + `, + }, testUtils.CreateDoc{ - DocMap: map[string]any{ - "name": "Andy", - "custom": "{\"tree\": \"oak\", \"age\": 450}", + Doc: `{ + "name": "John", + "custom": { + "level_1": { + "level_2": { + "level_3": [true, false] + } + } + } + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "Andy", + "custom": { + "level_1": { + "level_2": { + "level_3": [false, true] + } + } + } + }`, + }, + testUtils.Request{ + Request: `query { + Users(filter: {custom: {_eq: {level_1: {level_2: {level_3: [true, false]}}}}}) { + name + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + {"name": "John"}, + }, }, }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestQuerySimple_WithEqOpOnJSONFieldWithNullValue_ShouldFilter(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String + custom: JSON + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "John", + "custom": null + }`, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "Andy", + "custom": {} + }`, + }, testUtils.Request{ - // the filtered-by JSON has no spaces, because this is now it's stored. Request: `query { - Users(filter: {custom: {_eq: "{\"tree\":\"oak\",\"age\":450}"}}) { + Users(filter: {custom: {_eq: null}}) { name } }`, Results: map[string]any{ "Users": []map[string]any{ - {"name": "Andy"}, + {"name": "John"}, }, }, }, diff --git a/tests/integration/query/simple/with_filter/with_in_json_test.go b/tests/integration/query/simple/with_filter/with_in_json_test.go index b9bab035f4..568862ee52 100644 --- a/tests/integration/query/simple/with_filter/with_in_json_test.go +++ b/tests/integration/query/simple/with_filter/with_in_json_test.go @@ -28,21 +28,26 @@ func TestQuerySimple_WithInOpOnJSONField_ShouldFilter(t *testing.T) { `, }, testUtils.CreateDoc{ - DocMap: map[string]any{ - "name": "John", - "custom": "{\"tree\": \"maple\", \"age\": 250}", - }, + Doc: `{ + "name": "John", + "custom": { + "tree": "maple", + "age": 250 + } + }`, }, testUtils.CreateDoc{ - DocMap: map[string]any{ - "name": "Andy", - "custom": "{\"tree\": \"oak\", \"age\": 450}", - }, + Doc: `{ + "name": "Andy", + "custom": { + "tree": "oak", + "age": 450 + } + }`, }, testUtils.Request{ - // the filtered-by JSON has no spaces, because this is now it's stored. Request: `query { - Users(filter: {custom: {_in: ["{\"tree\":\"oak\",\"age\":450}"]}}) { + Users(filter: {custom: {_in: [{tree:"oak",age:450}]}}) { name } }`, diff --git a/tests/integration/schema/updates/add/field/kind/json_test.go b/tests/integration/schema/updates/add/field/kind/json_test.go index 371e12d074..faecdb1977 100644 --- a/tests/integration/schema/updates/add/field/kind/json_test.go +++ b/tests/integration/schema/updates/add/field/kind/json_test.go @@ -72,7 +72,7 @@ func TestSchemaUpdatesAddFieldKindJSONWithCreate(t *testing.T) { CollectionID: 0, Doc: `{ "name": "John", - "foo": "{}" + "foo": {} }`, }, testUtils.Request{ @@ -86,7 +86,7 @@ func TestSchemaUpdatesAddFieldKindJSONWithCreate(t *testing.T) { "Users": []map[string]any{ { "name": "John", - "foo": "{}", + "foo": map[string]any{}, }, }, }, @@ -118,7 +118,7 @@ func TestSchemaUpdatesAddFieldKindJSONSubstitutionWithCreate(t *testing.T) { CollectionID: 0, Doc: `{ "name": "John", - "foo": "{}" + "foo": {} }`, }, testUtils.Request{ @@ -132,7 +132,7 @@ func TestSchemaUpdatesAddFieldKindJSONSubstitutionWithCreate(t *testing.T) { "Users": []map[string]any{ { "name": "John", - "foo": "{}", + "foo": map[string]any{}, }, }, },