Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion execution/engine/execution_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/wundergraph/graphql-go-tools/v2/pkg/ast"
"github.com/wundergraph/graphql-go-tools/v2/pkg/astnormalization"
"github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter"
"github.com/wundergraph/graphql-go-tools/v2/pkg/astvalidation"
"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/introspection_datasource"
"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan"
"github.com/wundergraph/graphql-go-tools/v2/pkg/engine/postprocess"
Expand Down Expand Up @@ -62,6 +63,7 @@ type ExecutionEngine struct {
resolver *resolve.Resolver
executionPlanCache *lru.Cache
apolloCompatibilityFlags apollocompatibility.Flags
validationOptions []astvalidation.Option
}

type WebsocketBeforeStartHook interface {
Expand Down Expand Up @@ -130,6 +132,11 @@ func NewExecutionEngine(ctx context.Context, logger abstractlogger.Logger, engin
dsIDs[ds.Id()] = struct{}{}
}

var validationOpts []astvalidation.Option
if engineConfig.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability {
validationOpts = append(validationOpts, astvalidation.WithRelaxFieldSelectionMergingNullability())
}

return &ExecutionEngine{
logger: logger,
config: engineConfig,
Expand All @@ -138,6 +145,7 @@ func NewExecutionEngine(ctx context.Context, logger abstractlogger.Logger, engin
apolloCompatibilityFlags: apollocompatibility.Flags{
ReplaceInvalidVarError: resolverOptions.ResolvableOptions.ApolloCompatibilityReplaceInvalidVarError,
},
validationOptions: validationOpts,
}, nil
}

Expand All @@ -159,7 +167,7 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques
}

// Validate the operation against the schema.
if result, err := operation.ValidateForSchema(e.config.schema); err != nil {
if result, err := operation.ValidateForSchema(e.config.schema, e.validationOptions...); err != nil {
return err
} else if !result.Valid {
return result.Errors
Expand Down
138 changes: 137 additions & 1 deletion execution/engine/execution_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ type _executionTestOptions struct {
propagateFetchReasons bool
validateRequiredExternalFields bool
computeStaticCost bool
relaxFieldSelectionMergingNullability bool
}

type executionTestOptions func(*_executionTestOptions)
Expand Down Expand Up @@ -253,6 +254,12 @@ func computeStaticCost() executionTestOptions {
}
}

func relaxFieldSelectionMergingNullability() executionTestOptions {
return func(options *_executionTestOptions) {
options.relaxFieldSelectionMergingNullability = true
}
}

func TestExecutionEngine_Execute(t *testing.T) {
run := func(testCase ExecutionEngineTestCase, withError bool, expectedErrorMessage string, options ...executionTestOptions) func(t *testing.T) {
t.Helper()
Expand Down Expand Up @@ -289,6 +296,7 @@ func TestExecutionEngine_Execute(t *testing.T) {
engineConf.plannerConfig.ValidateRequiredExternalFields = opts.validateRequiredExternalFields
engineConf.plannerConfig.ComputeStaticCost = opts.computeStaticCost
engineConf.plannerConfig.StaticCostDefaultListSize = 10
engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability
resolveOpts := resolve.ResolverOptions{
MaxConcurrency: 1024,
ResolvableOptions: opts.resolvableOptions,
Expand Down Expand Up @@ -321,7 +329,7 @@ func TestExecutionEngine_Execute(t *testing.T) {
if withError {
require.Error(t, err)
if expectedErrorMessage != "" {
assert.Contains(t, err.Error(), expectedErrorMessage)
assert.Equal(t, expectedErrorMessage, err.Error())
}
} else {
require.NoError(t, err)
Expand Down Expand Up @@ -6812,6 +6820,134 @@ func TestExecutionEngine_Execute(t *testing.T) {
})

})

t.Run("field merging with different nullability on non-overlapping union types", func(t *testing.T) {
unionSchema := `
union Entity = User | Organization
type Query { entity: Entity }
type User { id: ID!, email: String! }
type Organization { id: ID!, email: String }
`
schema, err := graphql.NewSchemaFromString(unionSchema)
require.NoError(t, err)

rootNodes := []plan.TypeField{
{TypeName: "Query", FieldNames: []string{"entity"}},
{TypeName: "User", FieldNames: []string{"id", "email"}},
{TypeName: "Organization", FieldNames: []string{"id", "email"}},
}

customConfig := mustConfiguration(t, graphql_datasource.ConfigurationInput{
Fetch: &graphql_datasource.FetchConfiguration{
URL: "https://example.com/",
Method: "POST",
},
SchemaConfiguration: mustSchemaConfig(t, nil, unionSchema),
})

fieldConfig := []plan.FieldConfiguration{
{
TypeName: "Query",
FieldName: "entity",
Path: []string{"entity"},
},
}

t.Run("without relaxation flag, validation rejects differing nullability", runWithAndCompareError(
ExecutionEngineTestCase{
schema: schema,
operation: func(t *testing.T) graphql.Request {
return graphql.Request{
OperationName: "O",
Query: `query O { entity { ... on User { email } ... on Organization { email } } }`,
}
},
dataSources: []plan.DataSource{
mustGraphqlDataSourceConfiguration(t, "ds-id",
mustFactory(t,
testNetHttpClient(t, roundTripperTestCase{
expectedHost: "example.com",
expectedPath: "/",
expectedBody: "",
sendResponseBody: `{"data":{"entity":{"__typename":"User","email":"user@test.com"}}}`,
sendStatusCode: 200,
}),
),
&plan.DataSourceMetadata{
RootNodes: rootNodes,
},
customConfig,
),
},
fields: fieldConfig,
},
`fields 'email' conflict because they return conflicting types 'String!' and 'String', locations: [], path: [query,entity,$1Organization]`,
))

t.Run("non-null email from User type", runWithoutError(
ExecutionEngineTestCase{
schema: schema,
operation: func(t *testing.T) graphql.Request {
return graphql.Request{
OperationName: "O",
Query: `query O { entity { ... on User { email } ... on Organization { email } } }`,
}
},
dataSources: []plan.DataSource{
mustGraphqlDataSourceConfiguration(t, "ds-id",
mustFactory(t,
testNetHttpClient(t, roundTripperTestCase{
expectedHost: "example.com",
expectedPath: "/",
expectedBody: "",
sendResponseBody: `{"data":{"entity":{"__typename":"User","email":"user@test.com"}}}`,
sendStatusCode: 200,
}),
),
&plan.DataSourceMetadata{
RootNodes: rootNodes,
},
customConfig,
),
},
fields: fieldConfig,
expectedResponse: `{"data":{"entity":{"email":"user@test.com"}}}`,
},
relaxFieldSelectionMergingNullability(),
))

t.Run("null email from Organization type", runWithoutError(
ExecutionEngineTestCase{
schema: schema,
operation: func(t *testing.T) graphql.Request {
return graphql.Request{
OperationName: "O",
Query: `query O { entity { ... on User { email } ... on Organization { email } } }`,
}
},
dataSources: []plan.DataSource{
mustGraphqlDataSourceConfiguration(t, "ds-id",
mustFactory(t,
testNetHttpClient(t, roundTripperTestCase{
expectedHost: "example.com",
expectedPath: "/",
expectedBody: "",
sendResponseBody: `{"data":{"entity":{"__typename":"Organization","email":null}}}`,
sendStatusCode: 200,
}),
),
&plan.DataSourceMetadata{
RootNodes: rootNodes,
},
customConfig,
),
},
fields: fieldConfig,
expectedResponse: `{"data":{"entity":{"email":null}}}`,
},
relaxFieldSelectionMergingNullability(),
))
})
}

func testNetHttpClient(t *testing.T, testCase roundTripperTestCase) *http.Client {
Expand Down
15 changes: 10 additions & 5 deletions execution/graphql/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,38 @@ type ValidationResult struct {
Errors graphqlerrors.Errors
}

func (r *Request) ValidateForSchema(schema *Schema) (result ValidationResult, err error) {
func (r *Request) ValidateForSchema(schema *Schema, options ...astvalidation.Option) (result ValidationResult, err error) {
if schema == nil {
return ValidationResult{Valid: false, Errors: nil}, ErrNilSchema
}

schemaHash := schema.Hash()
useCache := len(options) == 0

if r.validForSchema == nil {
r.validForSchema = map[uint64]ValidationResult{}
}

if result, ok := r.validForSchema[schemaHash]; ok {
return result, nil
if useCache {
if result, ok := r.validForSchema[schemaHash]; ok {
return result, nil
}
}

report := r.parseQueryOnce()
if report.HasErrors() {
return operationValidationResultFromReport(report)
}

validator := astvalidation.DefaultOperationValidator()
validator := astvalidation.DefaultOperationValidator(options...)
validator.Validate(&r.document, &schema.document, &report)
result, err = operationValidationResultFromReport(report)
if err != nil {
return result, err
}
r.validForSchema[schemaHash] = result
if useCache {
r.validForSchema[schemaHash] = result
}
return result, err
}

Expand Down
27 changes: 27 additions & 0 deletions v2/pkg/ast/ast_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,37 @@ func (d *Document) TypeNumberOfListWraps(ref int) int {
}

func (d *Document) TypesAreCompatibleDeep(left int, right int) bool {
return d.typesAreCompatible(left, right, false)
}

// TypesAreCompatibleIgnoringNullability is a relaxed variant of TypesAreCompatibleDeep
// that strips NonNull wrappers at every nesting level but still requires the same list
// structure and the same base named type.
//
// Use this only when the two fields' enclosing types cannot overlap at runtime
// (i.e. potentiallySameObject returns false), for example two distinct concrete
// object types inside inline fragments on the same union/interface field. In that
// case the spec's SameResponseShape algorithm permits differing nullability because
// only one branch can ever execute for a given runtime object.
//
// See https://spec.graphql.org/October2021/#SameResponseShape()
func (d *Document) TypesAreCompatibleIgnoringNullability(left int, right int) bool {
return d.typesAreCompatible(left, right, true)
}

func (d *Document) typesAreCompatible(left int, right int, ignoreNullability bool) bool {
for {
if left == -1 || right == -1 {
return false
}
if ignoreNullability {
if d.Types[left].TypeKind == TypeKindNonNull {
left = d.Types[left].OfType
}
if d.Types[right].TypeKind == TypeKindNonNull {
right = d.Types[right].OfType
}
}
if d.Types[left].TypeKind != d.Types[right].TypeKind {
return false
}
Expand Down
Loading