diff --git a/argument_coercion_test.go b/argument_coercion_test.go new file mode 100644 index 0000000..104c9d5 --- /dev/null +++ b/argument_coercion_test.go @@ -0,0 +1,137 @@ +package graphql_test + +import ( + "encoding/json" + "sort" + "testing" + + "github.com/tailor-platform/graphql" + "github.com/tailor-platform/graphql/testutil" +) + +// Serialises p.Args so tests can tell "absent", "null", and "value" apart. +func probeArgs(p graphql.ResolveParams) (interface{}, error) { + keys := make([]string, 0, len(p.Args)) + for k := range p.Args { + keys = append(keys, k) + } + sort.Strings(keys) + out := map[string]interface{}{"keys": keys} + for _, k := range keys { + v := p.Args[k] + if v == nil { + out[k] = "null" + } else { + out[k] = v + } + } + b, _ := json.Marshal(out) + return string(b), nil +} + +var coercionProbeInputObject = graphql.NewInputObject(graphql.InputObjectConfig{ + Name: "CoercionProbeInput", + Fields: graphql.InputObjectConfigFieldMap{ + "a": &graphql.InputObjectFieldConfig{Type: graphql.String}, + "b": &graphql.InputObjectFieldConfig{Type: graphql.String}, + }, +}) + +var coercionProbeType = graphql.NewObject(graphql.ObjectConfig{ + Name: "CoercionProbeQuery", + Fields: graphql.Fields{ + "probe": &graphql.Field{ + Type: graphql.String, + Args: graphql.FieldConfigArgument{ + "a": &graphql.ArgumentConfig{Type: graphql.String}, + "b": &graphql.ArgumentConfig{Type: graphql.String}, + }, + Resolve: probeArgs, + }, + "probeObject": &graphql.Field{ + Type: graphql.String, + Args: graphql.FieldConfigArgument{ + "input": &graphql.ArgumentConfig{Type: coercionProbeInputObject}, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + obj, _ := p.Args["input"].(map[string]interface{}) + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) + b, _ := json.Marshal(map[string]interface{}{ + "keys": keys, + "obj": obj, + }) + return string(b), nil + }, + }, + }, +}) + +var coercionProbeSchema, _ = graphql.NewSchema(graphql.SchemaConfig{Query: coercionProbeType}) + +func runProbe(t *testing.T, field, doc string, vars map[string]interface{}, want string) { + t.Helper() + parsed := testutil.TestParse(t, doc) + result := testutil.TestExecute(t, graphql.ExecuteParams{ + Schema: coercionProbeSchema, + AST: parsed, + Args: vars, + }) + if len(result.Errors) > 0 { + t.Fatalf("unexpected errors: %v", result.Errors) + } + data, _ := result.Data.(map[string]interface{}) + got, _ := data[field].(string) + if got != want { + t.Fatalf("probe mismatch\n got: %s\n want: %s", got, want) + } +} + +func TestArgumentCoercion_ScalarVariable_PreservesThreeStates(t *testing.T) { + doc := `query Probe($a: String, $b: String) { probe(a: $a, b: $b) }` + + t.Run("variable omitted -> argument absent", func(t *testing.T) { + runProbe(t, "probe", doc, + map[string]interface{}{"a": "x"}, + `{"a":"x","keys":["a"]}`) + }) + t.Run("variable explicitly null -> argument present as null", func(t *testing.T) { + runProbe(t, "probe", doc, + map[string]interface{}{"a": "x", "b": nil}, + `{"a":"x","b":"null","keys":["a","b"]}`) + }) + t.Run("variable with value -> argument present with value", func(t *testing.T) { + runProbe(t, "probe", doc, + map[string]interface{}{"a": "x", "b": "y"}, + `{"a":"x","b":"y","keys":["a","b"]}`) + }) +} + +func TestArgumentCoercion_InputObjectVariable_PreservesThreeStates(t *testing.T) { + doc := `query Probe($a: String, $b: String) { probeObject(input: {a: $a, b: $b}) }` + + t.Run("nested variable omitted -> field absent in object", func(t *testing.T) { + runProbe(t, "probeObject", doc, + map[string]interface{}{"a": "x"}, + `{"keys":["a"],"obj":{"a":"x"}}`) + }) + t.Run("nested variable explicitly null -> field present as null", func(t *testing.T) { + runProbe(t, "probeObject", doc, + map[string]interface{}{"a": "x", "b": nil}, + `{"keys":["a","b"],"obj":{"a":"x","b":null}}`) + }) + t.Run("nested variable with value -> field present with value", func(t *testing.T) { + runProbe(t, "probeObject", doc, + map[string]interface{}{"a": "x", "b": "y"}, + `{"keys":["a","b"],"obj":{"a":"x","b":"y"}}`) + }) +} + +func TestArgumentCoercion_InputObjectLiteral_OmittedFieldStaysAbsent(t *testing.T) { + doc := `{ probeObject(input: {a: "x"}) }` + runProbe(t, "probeObject", doc, nil, + `{"keys":["a"],"obj":{"a":"x"}}`) +} diff --git a/values.go b/values.go index 4ed4b46..3a49bc1 100644 --- a/values.go +++ b/values.go @@ -27,9 +27,12 @@ func getVariableValues( continue } varName := defAST.Variable.Name.Value - if varValue, err := getVariableValue(schema, defAST, inputs[varName]); err != nil { + input, provided := inputs[varName] + varValue, err := getVariableValue(schema, defAST, input) + if err != nil { return values, err - } else { + } + if provided || defAST.DefaultValue != nil { values[varName] = varValue } } @@ -50,28 +53,33 @@ func getArgumentValues( } results := map[string]interface{}{} for _, argDef := range argDefs { - var ( - tmp interface{} - value ast.Value - isUndefined bool - ) - if tmpValue, ok := argASTMap[argDef.PrivateName]; ok { - value = tmpValue.Value - } else { - isUndefined = true - } - if tmp = valueFromAST(value, argDef.Type, variableValues); isNullish(tmp) { + var value ast.Value + argAST, ok := argASTMap[argDef.PrivateName] + if ok { + value = argAST.Value + } + isUndefined := !ok || isUnprovidedVariable(value, variableValues) + tmp := valueFromAST(value, argDef.Type, variableValues) + if isNullish(tmp) { tmp = argDef.DefaultValue } - if !isUndefined && tmp == nil { - results[argDef.PrivateName] = nil - } else if !isNullish(tmp) { + if !isUndefined || !isNullish(tmp) { results[argDef.PrivateName] = tmp } } return results } +// Returns true if value is a reference to a variable the caller did not supply. +func isUnprovidedVariable(value ast.Value, variables map[string]interface{}) bool { + v, ok := value.(*ast.Variable) + if !ok || v.Name == nil { + return false + } + _, provided := variables[v.Name.Value] + return !provided +} + // Given a variable definition, and any value of input, return a value which // adheres to the variable definition, or throw an error. func getVariableValue(schema Schema, definitionAST *ast.VariableDefinition, input interface{}) (interface{}, error) { @@ -381,16 +389,12 @@ func valueFromAST(valueAST ast.Value, ttype Input, variables map[string]interfac } return append(values, valueFromAST(valueAST, ttype.OfType, variables)) case *InputObject: - var ( - ok bool - ov *ast.ObjectValue - of *ast.ObjectField - ) - if ov, ok = valueAST.(*ast.ObjectValue); !ok { + ov, ok := valueAST.(*ast.ObjectValue) + if !ok { return nil } fieldASTs := map[string]*ast.ObjectField{} - for _, of = range ov.Fields { + for _, of := range ov.Fields { if of == nil || of.Name == nil { continue } @@ -398,20 +402,12 @@ func valueFromAST(valueAST ast.Value, ttype Input, variables map[string]interfac } obj := map[string]interface{}{} for name, field := range ttype.Fields() { - var ( - value interface{} - isUndefined bool - ) - if of, ok = fieldASTs[name]; ok { - value = valueFromAST(of.Value, field.Type, variables) - } else { - isUndefined = true - value = field.DefaultValue - } - if !isUndefined && value == nil { - obj[name] = nil - } else if !isNullish(value) { - obj[name] = value + of, ok := fieldASTs[name] + supplied := ok && !isUnprovidedVariable(of.Value, variables) + if supplied { + obj[name] = valueFromAST(of.Value, field.Type, variables) + } else if !isNullish(field.DefaultValue) { + obj[name] = field.DefaultValue } } return obj