Skip to content
Closed
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
3 changes: 3 additions & 0 deletions execution/engine/execution_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ func NewExecutionEngine(ctx context.Context, logger abstractlogger.Logger, engin
if engineConfig.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability {
validationOpts = append(validationOpts, astvalidation.WithRelaxFieldSelectionMergingNullability())
}
if engineConfig.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingTypeMismatch {
validationOpts = append(validationOpts, astvalidation.WithRelaxFieldSelectionMergingTypeMismatch())
}

return &ExecutionEngine{
logger: logger,
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 @@ -224,6 +224,7 @@ type _executionTestOptions struct {
validateRequiredExternalFields bool
computeStaticCost bool
relaxFieldSelectionMergingNullability bool
relaxFieldSelectionMergingTypeMismatch bool
}

type executionTestOptions func(*_executionTestOptions)
Expand Down Expand Up @@ -260,6 +261,12 @@ func relaxFieldSelectionMergingNullability() executionTestOptions {
}
}

func relaxFieldSelectionMergingTypeMismatch() executionTestOptions {
return func(options *_executionTestOptions) {
options.relaxFieldSelectionMergingTypeMismatch = 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 @@ -297,6 +304,7 @@ func TestExecutionEngine_Execute(t *testing.T) {
engineConf.plannerConfig.ComputeStaticCost = opts.computeStaticCost
engineConf.plannerConfig.StaticCostDefaultListSize = 10
engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingNullability = opts.relaxFieldSelectionMergingNullability
engineConf.plannerConfig.RelaxSubgraphOperationFieldSelectionMergingTypeMismatch = opts.relaxFieldSelectionMergingTypeMismatch
resolveOpts := resolve.ResolverOptions{
MaxConcurrency: 1024,
ResolvableOptions: opts.resolvableOptions,
Expand Down Expand Up @@ -6916,7 +6924,7 @@ func TestExecutionEngine_Execute(t *testing.T) {
relaxFieldSelectionMergingNullability(),
))

t.Run("null email from Organization type", runWithoutError(
t.Run("null email from Organization type with nullability relaxation", runWithoutError(
ExecutionEngineTestCase{
schema: schema,
operation: func(t *testing.T) graphql.Request {
Expand Down Expand Up @@ -6948,6 +6956,134 @@ func TestExecutionEngine_Execute(t *testing.T) {
relaxFieldSelectionMergingNullability(),
))
})

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

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

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 types", runWithAndCompareError(
ExecutionEngineTestCase{
schema: schema,
operation: func(t *testing.T) graphql.Request {
return graphql.Request{
OperationName: "O",
Query: `query O { entity { ... on User { score } ... on Organization { score } } }`,
}
},
dataSources: []plan.DataSource{
mustGraphqlDataSourceConfiguration(t, "ds-id",
mustFactory(t,
testNetHttpClient(t, roundTripperTestCase{
expectedHost: "example.com",
expectedPath: "/",
expectedBody: "",
sendResponseBody: `{"data":{"entity":{"__typename":"User","score":42}}}`,
sendStatusCode: 200,
}),
),
&plan.DataSourceMetadata{
RootNodes: rootNodes,
},
customConfig,
),
},
fields: fieldConfig,
},
`fields 'score' conflict because they return conflicting types 'Int' and 'String', locations: [], path: [query,entity,$1Organization]`,
))

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

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

func testNetHttpClient(t *testing.T, testCase roundTripperTestCase) *http.Client {
Expand Down
73 changes: 51 additions & 22 deletions v2/pkg/astvalidation/operation_rule_field_selection_merging.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,31 @@ import (
"github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport"
)

// FieldSelectionMergingOptions configures relaxation flags for the FieldSelectionMerging rule.
//
// RelaxNullabilityCheck allows fields with differing nullability (e.g. String! vs String)
// on non-overlapping concrete object types within inline fragments.
//
// RelaxTypeMismatchCheck is a strict superset: it allows completely differing field types
// (e.g. Int vs String) on non-overlapping concrete types. When set, RelaxNullabilityCheck
// has no additional effect.
type FieldSelectionMergingOptions struct {
RelaxNullabilityCheck bool
RelaxTypeMismatchCheck bool
}

// FieldSelectionMerging validates if field selections can be merged
func FieldSelectionMerging(relaxNullabilityCheck ...bool) Rule {
relax := len(relaxNullabilityCheck) > 0 && relaxNullabilityCheck[0]
func FieldSelectionMerging(options *FieldSelectionMergingOptions) Rule {
var opts FieldSelectionMergingOptions
if options != nil {
opts = *options
}
return func(walker *astvisitor.Walker) {
visitor := fieldSelectionMergingVisitor{Walker: walker, relaxNullabilityCheck: relax}
visitor := fieldSelectionMergingVisitor{
Walker: walker,
relaxNullabilityCheck: opts.RelaxNullabilityCheck,
relaxTypeMismatchCheck: opts.RelaxTypeMismatchCheck,
}
walker.RegisterEnterDocumentVisitor(&visitor)
walker.RegisterEnterFieldVisitor(&visitor)
walker.RegisterEnterOperationVisitor(&visitor)
Expand All @@ -24,11 +44,12 @@ func FieldSelectionMerging(relaxNullabilityCheck ...bool) Rule {
type fieldSelectionMergingVisitor struct {
*astvisitor.Walker

definition, operation *ast.Document
scalarRequirements scalarRequirements
nonScalarRequirements nonScalarRequirements
refs []int
relaxNullabilityCheck bool
definition, operation *ast.Document
scalarRequirements scalarRequirements
nonScalarRequirements nonScalarRequirements
refs []int
relaxNullabilityCheck bool
relaxTypeMismatchCheck bool
}
type nonScalarRequirement struct {
path ast.Path
Expand Down Expand Up @@ -119,13 +140,7 @@ func (f *fieldSelectionMergingVisitor) EnterField(ref int) {
return
}
} else if !f.definition.TypesAreCompatibleDeep(f.nonScalarRequirements[i].fieldTypeRef, fieldType) {
// Deliberate deviation from SameResponseShape (spec sec 5.3.2): when enclosing
// types cannot overlap at runtime (two distinct concrete object types),
// we allow nullability differences because only one branch will ever
// contribute to the response. This is gated behind relaxNullabilityCheck.
if !f.relaxNullabilityCheck ||
f.potentiallySameObject(f.nonScalarRequirements[i].enclosingTypeDefinition, f.EnclosingTypeDefinition) ||
!f.definition.TypesAreCompatibleIgnoringNullability(f.nonScalarRequirements[i].fieldTypeRef, fieldType) {
if !f.canRelaxTypeMismatch(f.nonScalarRequirements[i].enclosingTypeDefinition, f.nonScalarRequirements[i].fieldTypeRef, fieldType) {
left, err := f.definition.PrintTypeBytes(f.nonScalarRequirements[i].fieldTypeRef, nil)
if err != nil {
f.StopWithInternalErr(err)
Expand Down Expand Up @@ -172,13 +187,7 @@ func (f *fieldSelectionMergingVisitor) EnterField(ref int) {
}
}
if !f.definition.TypesAreCompatibleDeep(f.scalarRequirements[i].fieldType, fieldType) {
// Per SameResponseShape (spec sec 5.3.2), when enclosing types cannot overlap
// at runtime (two distinct concrete object types), nullability differences are
// acceptable because only one branch will ever contribute to the response.
// This relaxation is gated behind the relaxNullabilityCheck flag.
if !f.relaxNullabilityCheck ||
f.potentiallySameObject(f.scalarRequirements[i].enclosingTypeDefinition, f.EnclosingTypeDefinition) ||
!f.definition.TypesAreCompatibleIgnoringNullability(f.scalarRequirements[i].fieldType, fieldType) {
if !f.canRelaxTypeMismatch(f.scalarRequirements[i].enclosingTypeDefinition, f.scalarRequirements[i].fieldType, fieldType) {
left, err := f.definition.PrintTypeBytes(f.scalarRequirements[i].fieldType, nil)
if err != nil {
f.StopWithInternalErr(err)
Expand Down Expand Up @@ -213,6 +222,26 @@ func (f *fieldSelectionMergingVisitor) EnterField(ref int) {
})
}

// canRelaxTypeMismatch reports whether a type incompatibility between two fields can
// be relaxed. This is a deliberate deviation from SameResponseShape (spec sec 5.3.2)
// for fields on non-overlapping concrete object types within inline fragments.
// Relaxation has two levels:
// - relaxTypeMismatchCheck (superset): skip the type compatibility check entirely,
// allowing completely different types (e.g. Int vs String). Covers nullability
// differences as a subset.
// - relaxNullabilityCheck: only relax when the underlying named types match but
// differ in nullability wrappers (e.g. String! vs String).
//
// Both require that the enclosing types are distinct concrete objects that cannot
// overlap at runtime.
func (f *fieldSelectionMergingVisitor) canRelaxTypeMismatch(enclosingType ast.Node, leftTypeRef, rightTypeRef int) bool {
if f.potentiallySameObject(enclosingType, f.EnclosingTypeDefinition) {
return false
}
return f.relaxTypeMismatchCheck ||
(f.relaxNullabilityCheck && f.definition.TypesAreCompatibleIgnoringNullability(leftTypeRef, rightTypeRef))
}

// potentiallySameObject reports whether two enclosing type definitions could apply
// to the same runtime object. This determines whether field merging must enforce
// strict type equality (including nullability) or may relax it.
Expand Down
20 changes: 17 additions & 3 deletions v2/pkg/astvalidation/operation_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import (
)

type OperationValidatorOptions struct {
ApolloCompatibilityFlags apollocompatibility.Flags
RelaxFieldSelectionMergingNullabilityCheck bool
ApolloCompatibilityFlags apollocompatibility.Flags
RelaxFieldSelectionMergingNullabilityCheck bool
RelaxFieldSelectionMergingTypeMismatchCheck bool
}

func WithApolloCompatibilityFlags(flags apollocompatibility.Flags) Option {
Expand All @@ -32,6 +33,16 @@ func WithRelaxFieldSelectionMergingNullability() Option {
}
}

// WithRelaxFieldSelectionMergingTypeMismatch enables a deliberate spec deviation that allows
// completely differing field types (e.g. IssueState vs PullRequestReviewState, or String vs Int)
// on fields in non-overlapping concrete object types within inline fragments.
// This is a superset of WithRelaxFieldSelectionMergingNullability.
func WithRelaxFieldSelectionMergingTypeMismatch() Option {
return func(options *OperationValidatorOptions) {
options.RelaxFieldSelectionMergingTypeMismatchCheck = true
}
}

type Option func(options *OperationValidatorOptions)

// DefaultOperationValidator returns a fully initialized OperationValidator with all default rules registered
Expand All @@ -58,7 +69,10 @@ func DefaultOperationValidator(options ...Option) *OperationValidator {
validator.RegisterRule(LoneAnonymousOperation())
validator.RegisterRule(SubscriptionSingleRootField())
validator.RegisterRule(FieldSelections())
validator.RegisterRule(FieldSelectionMerging(opts.RelaxFieldSelectionMergingNullabilityCheck))
validator.RegisterRule(FieldSelectionMerging(&FieldSelectionMergingOptions{
RelaxNullabilityCheck: opts.RelaxFieldSelectionMergingNullabilityCheck,
RelaxTypeMismatchCheck: opts.RelaxFieldSelectionMergingTypeMismatchCheck,
}))
validator.RegisterRule(KnownArguments())
validator.RegisterRule(Values())
validator.RegisterRule(ArgumentUniqueness())
Expand Down
Loading