From ea3a74f2f28f969eba760bd93d7a8b32e0d3a3f1 Mon Sep 17 00:00:00 2001 From: Keenan Nemetz Date: Mon, 16 Sep 2024 09:58:17 -0700 Subject: [PATCH] feat: Default scalar field values (#2997) ## Relevant issue(s) Resolves #2952 ## Description This PR adds support for default field values using a new `@default` directive. ## 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 integration tests Specify the platform(s) on which this was tested: - MacOS --- client/collection_field_description.go | 7 + client/definitions.go | 5 + client/document.go | 48 +++- docs/website/references/http/openapi.json | 2 + http/client_collection.go | 5 +- internal/db/fetcher/encoded_doc.go | 5 +- internal/request/graphql/schema/collection.go | 81 +++++- internal/request/graphql/schema/errors.go | 19 ++ internal/request/graphql/schema/generate.go | 3 +- internal/request/graphql/schema/manager.go | 1 + .../request/graphql/schema/types/types.go | 44 +++ tests/clients/cli/wrapper_collection.go | 5 +- .../updates/replace/fields_test.go | 24 ++ .../with_default_fields_test.go | 158 +++++++++++ .../create/with_default_values_test.go | 264 ++++++++++++++++++ .../view/simple/with_default_value_test.go | 70 +++++ 16 files changed, 715 insertions(+), 26 deletions(-) create mode 100644 tests/integration/collection_description/with_default_fields_test.go create mode 100644 tests/integration/mutation/create/with_default_values_test.go create mode 100644 tests/integration/view/simple/with_default_value_test.go diff --git a/client/collection_field_description.go b/client/collection_field_description.go index 98b012d641..bc066d52fc 100644 --- a/client/collection_field_description.go +++ b/client/collection_field_description.go @@ -38,6 +38,11 @@ type CollectionFieldDescription struct { // // Otherwise will be [None]. RelationName immutable.Option[string] + + // DefaultValue contains the default value for this field. + // + // This value has no effect on views. + DefaultValue any } func (f FieldID) String() string { @@ -50,6 +55,7 @@ type collectionFieldDescription struct { Name string ID FieldID RelationName immutable.Option[string] + DefaultValue any // Properties below this line are unmarshalled using custom logic in [UnmarshalJSON] Kind json.RawMessage @@ -64,6 +70,7 @@ func (f *CollectionFieldDescription) UnmarshalJSON(bytes []byte) error { f.Name = descMap.Name f.ID = descMap.ID + f.DefaultValue = descMap.DefaultValue f.RelationName = descMap.RelationName kind, err := parseFieldKind(descMap.Kind) if err != nil { diff --git a/client/definitions.go b/client/definitions.go index af571d5983..269a703ac9 100644 --- a/client/definitions.go +++ b/client/definitions.go @@ -145,6 +145,9 @@ type FieldDefinition struct { // If true, this is the primary half of a relation, otherwise is false. IsPrimaryRelation bool + + // DefaultValue contains the default value for this field. + DefaultValue any } // NewFieldDefinition returns a new [FieldDefinition], combining the given local and global elements @@ -164,6 +167,7 @@ func NewFieldDefinition(local CollectionFieldDescription, global SchemaFieldDesc RelationName: local.RelationName.Value(), Typ: global.Typ, IsPrimaryRelation: kind.IsObject() && !kind.IsArray(), + DefaultValue: local.DefaultValue, } } @@ -174,6 +178,7 @@ func NewLocalFieldDefinition(local CollectionFieldDescription) FieldDefinition { ID: local.ID, Kind: local.Kind.Value(), RelationName: local.RelationName.Value(), + DefaultValue: local.DefaultValue, } } diff --git a/client/document.go b/client/document.go index 82f0a8bb36..fa4c842343 100644 --- a/client/document.go +++ b/client/document.go @@ -84,25 +84,34 @@ type Document struct { collectionDefinition CollectionDefinition } -func newEmptyDoc(collectionDefinition CollectionDefinition) *Document { - return &Document{ +func newEmptyDoc(collectionDefinition CollectionDefinition) (*Document, error) { + doc := &Document{ fields: make(map[string]Field), values: make(map[Field]*FieldValue), collectionDefinition: collectionDefinition, } + if err := doc.setDefaultValues(); err != nil { + return nil, err + } + return doc, nil } // NewDocWithID creates a new Document with a specified key. -func NewDocWithID(docID DocID, collectionDefinition CollectionDefinition) *Document { - doc := newEmptyDoc(collectionDefinition) +func NewDocWithID(docID DocID, collectionDefinition CollectionDefinition) (*Document, error) { + doc, err := newEmptyDoc(collectionDefinition) + if err != nil { + return nil, err + } doc.id = docID - return doc + return doc, nil } // NewDocFromMap creates a new Document from a data map. func NewDocFromMap(data map[string]any, collectionDefinition CollectionDefinition) (*Document, error) { - var err error - doc := newEmptyDoc(collectionDefinition) + doc, err := newEmptyDoc(collectionDefinition) + if err != nil { + return nil, err + } // check if document contains special _docID field k, hasDocID := data[request.DocIDFieldName] @@ -142,8 +151,11 @@ func IsJSONArray(obj []byte) bool { // NewFromJSON creates a new instance of a Document from a raw JSON object byte array. func NewDocFromJSON(obj []byte, collectionDefinition CollectionDefinition) (*Document, error) { - doc := newEmptyDoc(collectionDefinition) - err := doc.SetWithJSON(obj) + doc, err := newEmptyDoc(collectionDefinition) + if err != nil { + return nil, err + } + err = doc.SetWithJSON(obj) if err != nil { return nil, err } @@ -172,7 +184,10 @@ func NewDocsFromJSON(obj []byte, collectionDefinition CollectionDefinition) ([]* if err != nil { return nil, err } - doc := newEmptyDoc(collectionDefinition) + doc, err := newEmptyDoc(collectionDefinition) + if err != nil { + return nil, err + } err = doc.setWithFastJSONObject(o) if err != nil { return nil, err @@ -653,6 +668,19 @@ func (doc *Document) setAndParseObjectType(value map[string]any) error { return nil } +func (doc *Document) setDefaultValues() error { + for _, field := range doc.collectionDefinition.GetFields() { + if field.DefaultValue == nil { + continue // no default value to set + } + err := doc.Set(field.Name, field.DefaultValue) + if err != nil { + return err + } + } + return nil +} + // Fields gets the document fields as a map. func (doc *Document) Fields() map[string]Field { doc.mu.RLock() diff --git a/docs/website/references/http/openapi.json b/docs/website/references/http/openapi.json index 0b511d7e5d..4462c3d9f3 100644 --- a/docs/website/references/http/openapi.json +++ b/docs/website/references/http/openapi.json @@ -83,6 +83,7 @@ "Fields": { "items": { "properties": { + "DefaultValue": {}, "ID": { "maximum": 4294967295, "minimum": 0, @@ -160,6 +161,7 @@ "Fields": { "items": { "properties": { + "DefaultValue": {}, "ID": { "maximum": 4294967295, "minimum": 0, diff --git a/http/client_collection.go b/http/client_collection.go index c13e4c68e9..54167de222 100644 --- a/http/client_collection.go +++ b/http/client_collection.go @@ -307,7 +307,10 @@ func (c *Collection) Get( if err != nil { return nil, err } - doc := client.NewDocWithID(docID, c.def) + doc, err := client.NewDocWithID(docID, c.def) + if err != nil { + return nil, err + } err = doc.SetWithJSON(data) if err != nil { return nil, err diff --git a/internal/db/fetcher/encoded_doc.go b/internal/db/fetcher/encoded_doc.go index 9bb3c6261c..1401626e29 100644 --- a/internal/db/fetcher/encoded_doc.go +++ b/internal/db/fetcher/encoded_doc.go @@ -112,7 +112,10 @@ func Decode(encdoc EncodedDocument, collectionDefinition client.CollectionDefini return nil, err } - doc := client.NewDocWithID(docID, collectionDefinition) + doc, err := client.NewDocWithID(docID, collectionDefinition) + if err != nil { + return nil, err + } properties, err := encdoc.Properties(false) if err != nil { return nil, err diff --git a/internal/request/graphql/schema/collection.go b/internal/request/graphql/schema/collection.go index 76835fd7c2..58536db3af 100644 --- a/internal/request/graphql/schema/collection.go +++ b/internal/request/graphql/schema/collection.go @@ -16,6 +16,7 @@ import ( "sort" "strings" + gql "github.com/sourcenetwork/graphql-go" "github.com/sourcenetwork/graphql-go/language/ast" gqlp "github.com/sourcenetwork/graphql-go/language/parser" "github.com/sourcenetwork/graphql-go/language/source" @@ -26,6 +27,29 @@ import ( "github.com/sourcenetwork/defradb/internal/request/graphql/schema/types" ) +const ( + typeID string = "ID" + typeBoolean string = "Boolean" + typeInt string = "Int" + typeFloat string = "Float" + typeDateTime string = "DateTime" + typeString string = "String" + typeBlob string = "Blob" + typeJSON string = "JSON" +) + +// this mapping is used to check that the default prop value +// matches the field type +var TypeToDefaultPropName = map[string]string{ + typeString: types.DefaultDirectivePropString, + typeBoolean: types.DefaultDirectivePropBool, + typeInt: types.DefaultDirectivePropInt, + typeFloat: types.DefaultDirectivePropFloat, + typeDateTime: types.DefaultDirectivePropDateTime, + typeJSON: types.DefaultDirectivePropJSON, + typeBlob: types.DefaultDirectivePropBlob, +} + // FromString parses a GQL SDL string into a set of collection descriptions. func FromString(ctx context.Context, schemaString string) ( []client.CollectionDefinition, @@ -369,6 +393,39 @@ func indexFieldFromAST(value ast.Value, defaultDirection *ast.EnumValue) (client }, nil } +func defaultFromAST( + field *ast.FieldDefinition, + directive *ast.Directive, +) (any, error) { + astNamed, ok := field.Type.(*ast.Named) + if !ok { + return nil, NewErrDefaultValueNotAllowed(field.Name.Value, field.Type.String()) + } + propName, ok := TypeToDefaultPropName[astNamed.Name.Value] + if !ok { + return nil, NewErrDefaultValueNotAllowed(field.Name.Value, astNamed.Name.Value) + } + var value any + for _, arg := range directive.Arguments { + if propName != arg.Name.Value { + return nil, NewErrDefaultValueInvalid(field.Name.Value, propName, arg.Name.Value) + } + switch t := arg.Value.(type) { + case *ast.IntValue: + value = gql.Int.ParseLiteral(arg.Value) + case *ast.FloatValue: + value = gql.Float.ParseLiteral(arg.Value) + case *ast.BooleanValue: + value = t.Value + case *ast.StringValue: + value = t.Value + default: + value = arg.Value.GetValue() + } + } + return value, nil +} + func fieldsFromAST( field *ast.FieldDefinition, hostObjectName string, @@ -392,6 +449,16 @@ func fieldsFromAST( } hostMap[field.Name.Value] = cType + var defaultValue any + for _, directive := range field.Directives { + if directive.Name.Value == types.DefaultDirectiveLabel { + defaultValue, err = defaultFromAST(field, directive) + if err != nil { + return nil, nil, err + } + } + } + schemaFieldDescriptions := []client.SchemaFieldDescription{} collectionFieldDescriptions := []client.CollectionFieldDescription{} @@ -467,7 +534,8 @@ func fieldsFromAST( collectionFieldDescriptions = append( collectionFieldDescriptions, client.CollectionFieldDescription{ - Name: field.Name.Value, + Name: field.Name.Value, + DefaultValue: defaultValue, }, ) } @@ -529,17 +597,6 @@ func setCRDTType(field *ast.FieldDefinition, kind client.FieldKind) (client.CTyp } func astTypeToKind(t ast.Type) (client.FieldKind, error) { - const ( - typeID string = "ID" - typeBoolean string = "Boolean" - typeInt string = "Int" - typeFloat string = "Float" - typeDateTime string = "DateTime" - typeString string = "String" - typeBlob string = "Blob" - typeJSON string = "JSON" - ) - switch astTypeVal := t.(type) { case *ast.List: switch innerAstTypeVal := astTypeVal.Type.(type) { diff --git a/internal/request/graphql/schema/errors.go b/internal/request/graphql/schema/errors.go index 304df792e6..a5150e291b 100644 --- a/internal/request/graphql/schema/errors.go +++ b/internal/request/graphql/schema/errors.go @@ -30,6 +30,8 @@ const ( errPolicyUnknownArgument string = "policy with unknown argument" errPolicyInvalidIDProp string = "policy directive with invalid id property" errPolicyInvalidResourceProp string = "policy directive with invalid resource property" + errDefaultValueInvalid string = "default value type must match field type" + errDefaultValueNotAllowed string = "default value is not allowed for this field type" ) var ( @@ -136,3 +138,20 @@ func NewErrRelationNotFound(relationName string) error { errors.NewKV("RelationName", relationName), ) } + +func NewErrDefaultValueInvalid(name string, expected string, actual string) error { + return errors.New( + errDefaultValueInvalid, + errors.NewKV("Name", name), + errors.NewKV("Expected", expected), + errors.NewKV("Actual", actual), + ) +} + +func NewErrDefaultValueNotAllowed(fieldName, fieldType string) error { + return errors.New( + errDefaultValueNotAllowed, + errors.NewKV("Name", fieldName), + errors.NewKV("Type", fieldType), + ) +} diff --git a/internal/request/graphql/schema/generate.go b/internal/request/graphql/schema/generate.go index c198296ffb..8ae36d230f 100644 --- a/internal/request/graphql/schema/generate.go +++ b/internal/request/graphql/schema/generate.go @@ -583,7 +583,8 @@ func (g *Generator) buildMutationInputTypes(collections []client.CollectionDefin } fields[field.Name] = &gql.InputObjectFieldConfig{ - Type: ttype, + Type: ttype, + DefaultValue: field.DefaultValue, } } diff --git a/internal/request/graphql/schema/manager.go b/internal/request/graphql/schema/manager.go index de2aa52ca3..66f1eb54c2 100644 --- a/internal/request/graphql/schema/manager.go +++ b/internal/request/graphql/schema/manager.go @@ -152,6 +152,7 @@ func defaultDirectivesType( ) []*gql.Directive { return []*gql.Directive{ schemaTypes.CRDTFieldDirective(crdtEnum), + schemaTypes.DefaultDirective(), schemaTypes.ExplainDirective(explainEnum), schemaTypes.PolicyDirective(), schemaTypes.IndexDirective(orderEnum, indexFieldInput), diff --git a/internal/request/graphql/schema/types/types.go b/internal/request/graphql/schema/types/types.go index 121bd4a3a4..393f9fe62a 100644 --- a/internal/request/graphql/schema/types/types.go +++ b/internal/request/graphql/schema/types/types.go @@ -42,6 +42,15 @@ const ( IndexFieldInputName = "name" IndexFieldInputDirection = "direction" + DefaultDirectiveLabel = "default" + DefaultDirectivePropString = "string" + DefaultDirectivePropBool = "bool" + DefaultDirectivePropInt = "int" + DefaultDirectivePropFloat = "float" + DefaultDirectivePropDateTime = "dateTime" + DefaultDirectivePropJSON = "json" + DefaultDirectivePropBlob = "blob" + FieldOrderASC = "ASC" FieldOrderDESC = "DESC" ) @@ -86,6 +95,41 @@ func ExplainEnum() *gql.Enum { }) } +func DefaultDirective() *gql.Directive { + return gql.NewDirective(gql.DirectiveConfig{ + Name: DefaultDirectiveLabel, + Description: `@default is a directive that can be used to set a default field value. + + Setting a default value on a field within a view has no effect.`, + Args: gql.FieldConfigArgument{ + DefaultDirectivePropString: &gql.ArgumentConfig{ + Type: gql.String, + }, + DefaultDirectivePropBool: &gql.ArgumentConfig{ + Type: gql.Boolean, + }, + DefaultDirectivePropInt: &gql.ArgumentConfig{ + Type: gql.Int, + }, + DefaultDirectivePropFloat: &gql.ArgumentConfig{ + Type: gql.Float, + }, + DefaultDirectivePropDateTime: &gql.ArgumentConfig{ + Type: gql.DateTime, + }, + DefaultDirectivePropJSON: &gql.ArgumentConfig{ + Type: JSONScalarType(), + }, + DefaultDirectivePropBlob: &gql.ArgumentConfig{ + Type: BlobScalarType(), + }, + }, + Locations: []string{ + gql.DirectiveLocationFieldDefinition, + }, + }) +} + func ExplainDirective(explainEnum *gql.Enum) *gql.Directive { return gql.NewDirective(gql.DirectiveConfig{ Name: ExplainLabel, diff --git a/tests/clients/cli/wrapper_collection.go b/tests/clients/cli/wrapper_collection.go index 9ef71e8ce7..d03c23532f 100644 --- a/tests/clients/cli/wrapper_collection.go +++ b/tests/clients/cli/wrapper_collection.go @@ -276,7 +276,10 @@ func (c *Collection) Get( if err != nil { return nil, err } - doc := client.NewDocWithID(docID, c.Definition()) + doc, err := client.NewDocWithID(docID, c.Definition()) + if err != nil { + return nil, err + } err = doc.SetWithJSON(data) if err != nil { return nil, err diff --git a/tests/integration/collection_description/updates/replace/fields_test.go b/tests/integration/collection_description/updates/replace/fields_test.go index 03aa8cdb1e..f984aa175d 100644 --- a/tests/integration/collection_description/updates/replace/fields_test.go +++ b/tests/integration/collection_description/updates/replace/fields_test.go @@ -37,3 +37,27 @@ func TestColDescrUpdateReplaceFields_Errors(t *testing.T) { testUtils.ExecuteTestCase(t, test) } + +func TestColDescrUpdateReplaceDefaultValue_Errors(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String @default(string: "Bob") + } + `, + }, + testUtils.PatchCollection{ + Patch: ` + [ + { "op": "replace", "path": "/1/Fields/1/DefaultValue", "value": "Alice" } + ] + `, + ExpectedError: "collection fields cannot be mutated. CollectionID: 1", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/collection_description/with_default_fields_test.go b/tests/integration/collection_description/with_default_fields_test.go new file mode 100644 index 0000000000..3821fd6359 --- /dev/null +++ b/tests/integration/collection_description/with_default_fields_test.go @@ -0,0 +1,158 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package collection_description + +import ( + "testing" + + "github.com/sourcenetwork/defradb/client" + testUtils "github.com/sourcenetwork/defradb/tests/integration" + + "github.com/sourcenetwork/immutable" +) + +func TestCollectionDescription_WithDefaultFieldValues(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + active: Boolean @default(bool: true) + created: DateTime @default(dateTime: "2000-07-23T03:00:00-00:00") + name: String @default(string: "Bob") + age: Int @default(int: 10) + points: Float @default(float: 30) + metadata: JSON @default(json: "{\"value\":1}") + image: Blob @default(blob: "ff0099") + } + `, + }, + testUtils.GetCollections{ + ExpectedResults: []client.CollectionDescription{ + { + Name: immutable.Some("Users"), + Fields: []client.CollectionFieldDescription{ + { + ID: 0, + Name: "_docID", + }, + { + ID: 1, + Name: "active", + DefaultValue: true, + }, + { + ID: 2, + Name: "age", + DefaultValue: float64(10), + }, + { + ID: 3, + Name: "created", + DefaultValue: "2000-07-23T03:00:00-00:00", + }, + { + ID: 4, + Name: "image", + DefaultValue: "ff0099", + }, + { + ID: 5, + Name: "metadata", + DefaultValue: "{\"value\":1}", + }, + { + ID: 6, + Name: "name", + DefaultValue: "Bob", + }, + { + ID: 7, + Name: "points", + DefaultValue: float64(30), + }, + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestCollectionDescription_WithIncorrectDefaultFieldValueType_ReturnsError(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + active: Boolean @default(int: 10) + } + `, + ExpectedError: "default value type must match field type. Name: active, Expected: bool, Actual: int", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestCollectionDescription_WithMultipleDefaultFieldValueTypes_ReturnsError(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String @default(string: "Bob", int: 10, bool: true, float: 10) + } + `, + ExpectedError: "default value type must match field type. Name: name, Expected: string, Actual: int", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestCollectionDescription_WithDefaultFieldValueOnRelation_ReturnsError(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User { + friend: User @default(string: "Bob") + } + `, + ExpectedError: "default value is not allowed for this field type. Name: friend, Type: User", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestCollectionDescription_WithDefaultFieldValueOnList_ReturnsError(t *testing.T) { + test := testUtils.TestCase{ + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User { + names: [String] @default(string: "Bob") + } + `, + ExpectedError: "default value is not allowed for this field type. Name: names, Type: List", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/mutation/create/with_default_values_test.go b/tests/integration/mutation/create/with_default_values_test.go new file mode 100644 index 0000000000..aeca8a1f0f --- /dev/null +++ b/tests/integration/mutation/create/with_default_values_test.go @@ -0,0 +1,264 @@ +// Copyright 2022 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package create + +import ( + "testing" + "time" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" + + "github.com/sourcenetwork/immutable" +) + +func TestMutationCreate_WithDefaultValues_NoValuesProvided_SetsDefaultValue(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple create mutation, with default values and no values provided", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + active: Boolean @default(bool: true) + created: DateTime @default(dateTime: "2000-07-23T03:00:00-00:00") + name: String @default(string: "Bob") + age: Int @default(int: 40) + points: Float @default(float: 10) + metadata: JSON @default(json: "{\"one\":1}") + image: Blob @default(blob: "ff0099") + } + `, + }, + testUtils.CreateDoc{ + // left empty to test default values + DocMap: map[string]any{}, + }, + testUtils.Request{ + Request: `query { + Users { + age + active + name + points + created + metadata + image + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "age": int64(40), + "active": true, + "name": "Bob", + "points": float64(10), + "created": time.Time(time.Date(2000, time.July, 23, 3, 0, 0, 0, time.UTC)), + "metadata": "{\"one\":1}", + "image": "ff0099", + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestMutationCreate_WithDefaultValues_NilValuesProvided_SetsNilValue(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple create mutation, with default values and null values provided", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + active: Boolean @default(bool: true) + created: DateTime @default(dateTime: "2000-07-23T03:00:00-00:00") + name: String @default(string: "Bob") + age: Int @default(int: 40) + points: Float @default(float: 10) + metadata: JSON @default(json: "{\"one\":1}") + image: Blob @default(blob: "ff0099") + } + `, + }, + testUtils.CreateDoc{ + DocMap: map[string]any{ + "age": nil, + "active": nil, + "name": nil, + "points": nil, + "created": nil, + "metadata": nil, + "image": nil, + }, + }, + testUtils.Request{ + Request: `query { + Users { + age + active + name + points + created + metadata + image + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "age": nil, + "active": nil, + "name": nil, + "points": nil, + "created": nil, + "metadata": nil, + "image": nil, + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestMutationCreate_WithDefaultValues_ValuesProvided_SetsValue(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple create mutation, with default values and values provided", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + active: Boolean @default(bool: true) + created: DateTime @default(dateTime: "2000-07-23T03:00:00-00:00") + name: String @default(string: "Bob") + age: Int @default(int: 40) + points: Float @default(float: 10) + metadata: JSON @default(json: "{\"one\":1}") + image: Blob @default(blob: "ff0099") + } + `, + }, + testUtils.CreateDoc{ + DocMap: map[string]any{ + "age": int64(50), + "active": false, + "name": "Alice", + "points": float64(5), + "created": time.Time(time.Date(2024, time.June, 18, 1, 0, 0, 0, time.UTC)), + "metadata": "{\"two\":2}", + "image": "aabb33", + }, + }, + testUtils.Request{ + Request: `query { + Users { + age + active + name + points + created + metadata + image + } + }`, + Results: map[string]any{ + "Users": []map[string]any{ + { + "age": int64(50), + "active": false, + "name": "Alice", + "points": float64(5), + "created": time.Time(time.Date(2024, time.June, 18, 1, 0, 0, 0, time.UTC)), + "metadata": "{\"two\":2}", + "image": "aabb33", + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestMutationCreate_WithDefaultValue_NoValueProvided_CreatedTwice_ReturnsError(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple create mutation, with default value, no value provided, and created twice", + SupportedMutationTypes: immutable.Some([]testUtils.MutationType{ + // This test will fail if using the collection save + // method because it does not create two unique docs + // and instead calls update on the second doc with + // matching fields + testUtils.CollectionNamedMutationType, + testUtils.GQLRequestMutationType, + }), + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String @default(string: "Bob") + age: Int @default(int: 40) + } + `, + }, + testUtils.CreateDoc{ + // left empty to test default values + DocMap: map[string]any{}, + }, + testUtils.CreateDoc{ + // left empty to test default values + DocMap: map[string]any{}, + ExpectedError: "a document with the given ID already exists", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} + +func TestMutationCreate_WithDefaultValue_NoValueProvided_CreatedTwice_UniqueIndex_ReturnsError(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple create mutation, with default value, no value provided, created twice, and unique index", + SupportedMutationTypes: immutable.Some([]testUtils.MutationType{ + // This test will fail if using the collection save + // method because it does not create two unique docs + // and instead calls update on the second doc with + // matching fields + testUtils.CollectionNamedMutationType, + testUtils.GQLRequestMutationType, + }), + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type Users { + name: String @default(string: "Bob") @index(unique: true) + age: Int @default(int: 40) + } + `, + }, + testUtils.CreateDoc{ + // left empty to test default values + DocMap: map[string]any{}, + }, + testUtils.CreateDoc{ + DocMap: map[string]any{ + "age": int64(50), + }, + ExpectedError: "can not index a doc's field(s) that violates unique index", + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +} diff --git a/tests/integration/view/simple/with_default_value_test.go b/tests/integration/view/simple/with_default_value_test.go new file mode 100644 index 0000000000..a7f5aa660c --- /dev/null +++ b/tests/integration/view/simple/with_default_value_test.go @@ -0,0 +1,70 @@ +// Copyright 2024 Democratized Data Foundation +// +// Use of this software is governed by the Business Source License +// included in the file licenses/BSL.txt. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0, included in the file +// licenses/APL.txt. + +package simple + +import ( + "testing" + + testUtils "github.com/sourcenetwork/defradb/tests/integration" +) + +func TestView_SimpleWithDefaultValue_DoesNotSetFieldValue(t *testing.T) { + test := testUtils.TestCase{ + Description: "Simple view with default value", + Actions: []any{ + testUtils.SchemaUpdate{ + Schema: ` + type User { + name: String + } + `, + }, + testUtils.CreateView{ + Query: ` + User { + name + } + `, + SDL: ` + type UserView { + name: String + age: Int @default(int: 40) + } + `, + }, + testUtils.CreateDoc{ + Doc: `{ + "name": "Alice" + }`, + }, + testUtils.Request{ + Request: ` + query { + UserView { + name + age + } + } + `, + Results: map[string]any{ + "UserView": []map[string]any{ + { + "name": "Alice", + "age": nil, + }, + }, + }, + }, + }, + } + + testUtils.ExecuteTestCase(t, test) +}