From 5774cb8362a3566f260eecd3111a39c91a437a4e Mon Sep 17 00:00:00 2001 From: David Ackroyd <23301187+dackroyd@users.noreply.github.com> Date: Mon, 3 Mar 2025 23:44:23 +1100 Subject: [PATCH] Split schema parsing Allow the schema definition parsing to be applied independently of making it executable. This enables multiple executable schemas to be created from a single schema parse. This also gives access to the AST without attaching the resolver immediately. The change in type for `SchemaOpt` isn't strictly a 100% backward compatible change. It should however be non-breaking for typical cases. Breakages may exist if usages define their own equivalent type from `func(*graphql.Schema)`. --- CHANGELOG.md | 6 ++ examples_test.go | 103 ++++++++++++++++++++++- graphql.go | 211 +++++++++++++++++++++++++++++++++++------------ graphql_test.go | 57 +++++++++++-- 4 files changed, 313 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 306282ca..08df1650 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +[Unreleased] + +* [FEATURE] Added ability to parse schemas independently of attaching resolvers. +This allows multiple executable schemas to be created from the same parsed +definition, with different schema options if required. + [v1.8.0](https://github.com/graph-gophers/graphql-go/releases/tag/v1.8.0) Release v1.8.0 * [FEATURE] Added `DecodeSelectedFieldArgs` helper function to decode argument values for any (nested) selected field path directly from a resolver context, enabling efficient multi-level prefetching without per-resolver argument reflection. This enables selective, multi‑level batching (Category → Products → Reviews) by loading only requested fields, mitigating N+1 issues despite complex filters or pagination. diff --git a/examples_test.go b/examples_test.go index 2c56b443..a5ea09d9 100644 --- a/examples_test.go +++ b/examples_test.go @@ -334,10 +334,10 @@ func (s *{{ $enum.Name }}) UnmarshalGraphQL(input interface{}) error { panic(err) } - opts := []graphql.SchemaOpt{ + opts := []graphql.SchemaDefOpt{ graphql.UseStringDescriptions(), } - schema := graphql.MustParseSchema(s, nil, opts...) + schema := graphql.MustParseSchemaDef(s, opts...) ast := schema.AST() seasons := ast.Enums[0] @@ -417,10 +417,10 @@ func ExampleUseStringDescriptions() { } ` - opts := []graphql.SchemaOpt{ + opts := []graphql.SchemaDefOpt{ graphql.UseStringDescriptions(), } - schema := graphql.MustParseSchema(s, nil, opts...) + schema := graphql.MustParseSchemaDef(s, opts...) ast := schema.AST() post := ast.Objects[1] @@ -490,3 +490,98 @@ func Example_resolverFieldTag() { // } // } } + +func Example_onlyParseSchemaDefinition() { + sdl := ` + schema { + query: Query + } + + type Query { + hello: String! + } + ` + + def := graphql.MustParseSchemaDef(sdl) + + ast := def.AST() + + fmt.Println(ast.Types["Query"].Kind()) + + // output: + // OBJECT +} + +func Example_multipleExecutableSchemas() { + def := graphql.MustParseSchemaDef(starwars.Schema, graphql.UseStringDescriptions()) + + schema1 := graphql.MustExecutableSchema(def, &starwars.Resolver{}, graphql.MaxDepth(8)) + schema2 := graphql.MustExecutableSchema(def, &starwars.Resolver{}, graphql.MaxDepth(4)) + + query := ` + query { + hero(episode:EMPIRE) { + name + friendsConnection(first: 1) { + friends { + name + friendsConnection(first: 1) { + friends { + id + } + } + } + } + } + }` + + res1 := schema1.Exec(context.Background(), query, "", nil) + res2 := schema2.Exec(context.Background(), query, "", nil) + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + + if err := enc.Encode(res1); err != nil { + panic(err) + } + + if err := enc.Encode(res2); err != nil { + panic(err) + } + + // output: + // { + // "data": { + // "hero": { + // "name": "Luke Skywalker", + // "friendsConnection": { + // "friends": [ + // { + // "name": "Han Solo", + // "friendsConnection": { + // "friends": [ + // { + // "id": "1000" + // } + // ] + // } + // } + // ] + // } + // } + // } + // } + // { + // "errors": [ + // { + // "message": "Field \"friends\" has depth 5 that exceeds max depth 4", + // "locations": [ + // { + // "line": 9, + // "column": 14 + // } + // ] + // } + // ] + // } +} diff --git a/graphql.go b/graphql.go index 712e90ed..b5e56e3c 100644 --- a/graphql.go +++ b/graphql.go @@ -25,15 +25,59 @@ import ( // the Go type signature of the resolvers does not match the schema. If nil is passed as the // resolver, then the schema can not be executed, but it may be inspected (e.g. with [Schema.ToJSON] or [Schema.AST]). func ParseSchema(schemaString string, resolver interface{}, opts ...SchemaOpt) (*Schema, error) { + var defOpts []SchemaDefOpt + var resOpts []SchemaResolveOpt + + for _, opt := range opts { + if o, ok := opt.(SchemaDefOpt); ok { + defOpts = append(defOpts, o) + } + + if o, ok := opt.(SchemaResolveOpt); ok { + resOpts = append(resOpts, o) + } + } + + def, err := ParseSchemaDef(schemaString, defOpts...) + if err != nil { + return nil, err + } + + return ExecutableSchema(def, resolver, resOpts...) +} + +// ParseSchemaDef parses a GraphQL schema definition. +func ParseSchemaDef(schemaString string, opts ...SchemaDefOpt) (*SchemaDef, error) { + s := &SchemaDef{ + schema: schema.New(), + } + + for _, opt := range opts { + opt.apply(s) + } + + if err := schema.Parse(s.schema, schemaString, s.useStringDescriptions); err != nil { + return nil, err + } + + if err := s.validateSchema(); err != nil { + return nil, err + } + + return s, nil +} + +// ExecutableSchema for a schema definition, using the provided resolver. +func ExecutableSchema(d *SchemaDef, resolver interface{}, opts ...SchemaResolveOpt) (*Schema, error) { s := &Schema{ - schema: schema.New(), + schema: d.schema, maxParallelism: 10, tracer: noop.Tracer{}, logger: &log.DefaultLogger{}, panicHandler: &errors.DefaultPanicHandler{}, } for _, opt := range opts { - opt(s) + opt.apply(s) } if s.validationTracer == nil { @@ -44,13 +88,6 @@ func ParseSchema(schemaString string, resolver interface{}, opts ...SchemaOpt) ( } } - if err := schema.Parse(s.schema, schemaString, s.useStringDescriptions); err != nil { - return nil, err - } - if err := s.validateSchema(); err != nil { - return nil, err - } - r, err := resolvable.ApplyResolver(s.schema, resolver, s.useFieldResolvers) if err != nil { return nil, err @@ -60,6 +97,16 @@ func ParseSchema(schemaString string, resolver interface{}, opts ...SchemaOpt) ( return s, nil } +// MustParseSchemaDef calls ParseSchemaDef and panics on error. +func MustParseSchemaDef(schemaString string, opts ...SchemaDefOpt) *SchemaDef { + s, err := ParseSchemaDef(schemaString, opts...) + if err != nil { + panic(err) + } + + return s +} + // MustParseSchema calls ParseSchema and panics on error. func MustParseSchema(schemaString string, resolver interface{}, opts ...SchemaOpt) *Schema { s, err := ParseSchema(schemaString, resolver, opts...) @@ -69,6 +116,16 @@ func MustParseSchema(schemaString string, resolver interface{}, opts ...SchemaOp return s } +// MustExecutableSchema calls ExecutableSchema and panics on error. +func MustExecutableSchema(d *SchemaDef, resolver interface{}, opts ...SchemaResolveOpt) *Schema { + s, err := ExecutableSchema(d, resolver, opts...) + if err != nil { + panic(err) + } + + return s +} + // Schema represents a GraphQL schema with an optional resolver. type Schema struct { schema *ast.Schema @@ -82,19 +139,31 @@ type Schema struct { validationTracer tracer.ValidationTracer logger log.Logger panicHandler errors.PanicHandler - useStringDescriptions bool subscribeResolverTimeout time.Duration useFieldResolvers bool disableFieldSelections bool overlapPairLimit int } +// SchemaDef represents a GraphQL schema definition, without a resolver. +type SchemaDef struct { + schema *ast.Schema + + useStringDescriptions bool +} + // AST returns the abstract syntax tree of the GraphQL schema definition. // It in turn can be used by other tools such as validators or generators. func (s *Schema) AST() *ast.Schema { return s.schema } +// AST returns the abstract syntax tree of the GraphQL schema definition. +// It in turn can be used by other tools such as validators or generators. +func (s *SchemaDef) AST() *ast.Schema { + return s.schema +} + // ASTSchema returns the abstract syntax tree of the GraphQL schema definition. // // Deprecated: use [Schema.AST] instead. @@ -102,25 +171,57 @@ func (s *Schema) ASTSchema() *ast.Schema { return s.schema } -// SchemaOpt is an option to pass to [ParseSchema] or [MustParseSchema]. -type SchemaOpt func(*Schema) +// SchemaOpt is an option for creating a GraphQL schema. +type SchemaOpt interface { + schemaOpt() +} + +// SchemaResolveOpt is an option for creating a resolvable GraphQL schema. +type SchemaResolveOpt interface { + SchemaOpt + + apply(*Schema) +} + +type schemaResolveOptFunc func(*Schema) + +func (f schemaResolveOptFunc) apply(d *Schema) { + f(d) +} + +func (schemaResolveOptFunc) schemaOpt() {} + +// SchemaDefOpt is an option for parsing a GraphQL schema definition. +type SchemaDefOpt interface { + SchemaOpt + + apply(*SchemaDef) +} + +type schemaDefOptFunc func(*SchemaDef) + +func (f schemaDefOptFunc) apply(d *SchemaDef) { + f(d) +} + +func (schemaDefOptFunc) schemaOpt() {} // UseStringDescriptions enables the usage of double quoted and triple quoted // strings as descriptions as per the [June 2018 spec]. When this is not enabled, // comments are parsed as descriptions instead. // // [June 2018 spec]: https://facebook.github.io/graphql/June2018/ -func UseStringDescriptions() SchemaOpt { - return func(s *Schema) { +func UseStringDescriptions() SchemaDefOpt { + return schemaDefOptFunc(func(s *SchemaDef) { s.useStringDescriptions = true - } + }) } // UseFieldResolvers specifies whether to use struct fields as resolvers. -func UseFieldResolvers() SchemaOpt { - return func(s *Schema) { +func UseFieldResolvers() SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.useFieldResolvers = true - } + }) } // DisableFieldSelections disables capturing child field selections for the @@ -129,28 +230,30 @@ func UseFieldResolvers() SchemaOpt { // selection context is stored. This is an opt-out for applications that never intend // to use the feature and want to avoid even its small lazy overhead. func DisableFieldSelections() SchemaOpt { - return func(s *Schema) { s.disableFieldSelections = true } + return schemaResolveOptFunc(func(s *Schema) { + s.disableFieldSelections = true + }) } // MaxDepth specifies the maximum field nesting depth in a query. The default is 0 which disables max depth checking. -func MaxDepth(n int) SchemaOpt { - return func(s *Schema) { +func MaxDepth(n int) SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.maxDepth = n - } + }) } // MaxParallelism specifies the maximum number of resolvers per request allowed to run in parallel. The default is 10. -func MaxParallelism(n int) SchemaOpt { - return func(s *Schema) { +func MaxParallelism(n int) SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.maxParallelism = n - } + }) } // MaxQueryLength specifies the maximum allowed query length in bytes. The default is 0 which disables max length checking. -func MaxQueryLength(n int) SchemaOpt { - return func(s *Schema) { +func MaxQueryLength(n int) SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.maxQueryLength = n - } + }) } // OverlapValidationLimit caps the number of overlapping selection pairs that will be examined @@ -158,37 +261,37 @@ func MaxQueryLength(n int) SchemaOpt { // When the cap is exceeded validation aborts early with an error (rule: OverlapValidationLimitExceeded) // to protect against maliciously constructed queries designed to exhaust memory/CPU. func OverlapValidationLimit(n int) SchemaOpt { - return func(s *Schema) { s.overlapPairLimit = n } + return schemaResolveOptFunc(func(s *Schema) { s.overlapPairLimit = n }) } // Tracer is used to trace queries and fields. It defaults to [noop.Tracer]. -func Tracer(t tracer.Tracer) SchemaOpt { - return func(s *Schema) { +func Tracer(t tracer.Tracer) SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.tracer = t - } + }) } // ValidationTracer is used to trace validation errors. It defaults to [tracer.LegacyNoopValidationTracer]. // Deprecated: context is needed to support tracing correctly. Use a tracer which implements [tracer.ValidationTracer]. -func ValidationTracer(tracer tracer.LegacyValidationTracer) SchemaOpt { //nolint:staticcheck - return func(s *Schema) { +func ValidationTracer(tracer tracer.LegacyValidationTracer) SchemaResolveOpt { //nolint:staticcheck + return schemaResolveOptFunc(func(s *Schema) { s.validationTracer = &validationBridgingTracer{tracer: tracer} - } + }) } // Logger is used to log panics during query execution. It defaults to [log.DefaultLogger]. -func Logger(logger log.Logger) SchemaOpt { - return func(s *Schema) { +func Logger(logger log.Logger) SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.logger = logger - } + }) } // PanicHandler is used to customize the panic errors during query execution. // It defaults to [errors.DefaultPanicHandler]. -func PanicHandler(panicHandler errors.PanicHandler) SchemaOpt { - return func(s *Schema) { +func PanicHandler(panicHandler errors.PanicHandler) SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.panicHandler = panicHandler - } + }) } // RestrictIntrospection accepts a filter func. If this function returns false the introspection is disabled, otherwise it is enabled. @@ -200,10 +303,10 @@ func PanicHandler(panicHandler errors.PanicHandler) SchemaOpt { // } // // Do not use it together with [DisableIntrospection], otherwise the option added last takes precedence. -func RestrictIntrospection(fn func(ctx context.Context) bool) SchemaOpt { - return func(s *Schema) { +func RestrictIntrospection(fn func(ctx context.Context) bool) SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.allowIntrospection = fn - } + }) } // DisableIntrospection disables introspection queries. This function is left for backwards compatibility reasons and is just a shorthand for: @@ -214,19 +317,19 @@ func RestrictIntrospection(fn func(ctx context.Context) bool) SchemaOpt { // graphql.RestrictIntrospection(filter) // // Deprecated: use [RestrictIntrospection] filter instead. Do not use it together with [RestrictIntrospection], otherwise the option added last takes precedence. -func DisableIntrospection() SchemaOpt { - return func(s *Schema) { +func DisableIntrospection() SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.allowIntrospection = func(context.Context) bool { return false } - } + }) } // SubscribeResolverTimeout is an option to control the amount of time // we allow for a single subscribe message resolver to complete it's job // before it times out and returns an error to the subscriber. -func SubscribeResolverTimeout(timeout time.Duration) SchemaOpt { - return func(s *Schema) { +func SubscribeResolverTimeout(timeout time.Duration) SchemaResolveOpt { + return schemaResolveOptFunc(func(s *Schema) { s.subscribeResolverTimeout = timeout - } + }) } // Response represents a typical response of a GraphQL server. It may be encoded to JSON directly or @@ -347,20 +450,20 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str } } -func (s *Schema) validateSchema() error { +func (d *SchemaDef) validateSchema() error { // https://graphql.github.io/graphql-spec/June2018/#sec-Root-Operation-Types // > The query root operation type must be provided and must be an Object type. - if err := validateRootOp(s.schema, "query", true); err != nil { + if err := validateRootOp(d.schema, "query", true); err != nil { return err } // > The mutation root operation type is optional; if it is not provided, the service does not support mutations. // > If it is provided, it must be an Object type. - if err := validateRootOp(s.schema, "mutation", false); err != nil { + if err := validateRootOp(d.schema, "mutation", false); err != nil { return err } // > Similarly, the subscription root operation type is also optional; if it is not provided, the service does not // > support subscriptions. If it is provided, it must be an Object type. - if err := validateRootOp(s.schema, "subscription", false); err != nil { + if err := validateRootOp(d.schema, "subscription", false); err != nil { return err } return nil diff --git a/graphql_test.go b/graphql_test.go index a61b43ea..27a69595 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -79,7 +79,9 @@ func (r *echoResolver) Echo(args struct{ Value *string }) *string { return args.Value } -var starwarsSchema = graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{}) +var starwarsSchemaDef = graphql.MustParseSchemaDef(starwars.Schema) + +var starwarsSchema = graphql.MustExecutableSchema(starwarsSchemaDef, &starwars.Resolver{}) type ResolverError interface { error @@ -163,6 +165,49 @@ func (r *discussPlanResolver) DismissVader(ctx context.Context) (string, error) return "", errors.New("I find your lack of faith disturbing") } +func TestParseSchemaDef_multipleExecutable(t *testing.T) { + t.Parallel() + + def := graphql.MustParseSchemaDef(starwars.Schema) + + // Multiple executable schemas, created from the same shared definition. + // These can apply distinct options, and resolve queries independently + s1 := graphql.MustExecutableSchema(def, &starwars.Resolver{}, graphql.MaxDepth(2)) + s2 := graphql.MustExecutableSchema(def, &starwars.Resolver{}, graphql.MaxQueryLength(20)) + + query := ` + query { + hero { + id + name + friends { + name + } + } + }` + + gqltesting.RunTests(t, []*gqltesting.Test{ + { + Schema: s1, + Query: query, + ExpectedErrors: []*gqlerrors.QueryError{{ + Message: `Field "name" has depth 3 that exceeds max depth 2`, + Rule: "MaxDepthExceeded", + Locations: []gqlerrors.Location{ + {Line: 7, Column: 6}, + }, + }}, + }, + { + Schema: s2, + Query: query, + ExpectedErrors: []*gqlerrors.QueryError{{ + Message: `query length 75 exceeds the maximum allowed query length of 20 bytes`, + }}, + }, + }) +} + func TestHelloWorld(t *testing.T) { t.Parallel() @@ -421,7 +466,7 @@ func TestRootOperations_invalidSchema(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - _, err := graphql.ParseSchema(tt.Args.Schema, nil) + _, err := graphql.ParseSchemaDef(tt.Args.Schema) if err == nil || err.Error() != tt.Want.Error { t.Logf("got: %v", err) t.Logf("want: %s", tt.Want.Error) @@ -2642,7 +2687,7 @@ func TestIntrospection(t *testing.T) { }) } -var starwarsSchemaNoIntrospection = graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{}, []graphql.SchemaOpt{graphql.DisableIntrospection()}...) +var starwarsSchemaNoIntrospection = graphql.MustExecutableSchema(starwarsSchemaDef, &starwars.Resolver{}, []graphql.SchemaResolveOpt{graphql.DisableIntrospection()}...) func TestIntrospectionDisableIntrospection(t *testing.T) { gqltesting.RunTests(t, []*gqltesting.Test{ @@ -4354,7 +4399,7 @@ func TestTracer(t *testing.T) { tt := &testTracer{mu: &sync.Mutex{}} - schema, err := graphql.ParseSchema(starwars.Schema, &starwars.Resolver{}, graphql.Tracer(tt)) + schema, err := graphql.ExecutableSchema(starwarsSchemaDef, &starwars.Resolver{}, graphql.Tracer(tt)) if err != nil { t.Fatalf("graphql.ParseSchema: %s", err) } @@ -4564,7 +4609,7 @@ func TestInterfaceImplementingInterface(t *testing.T) { } func TestCircularFragmentMaxDepth(t *testing.T) { - withMaxDepth := graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{}, graphql.MaxDepth(2)) + withMaxDepth := graphql.MustExecutableSchema(starwarsSchemaDef, &starwars.Resolver{}, graphql.MaxDepth(2)) gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: withMaxDepth, @@ -4593,7 +4638,7 @@ func TestCircularFragmentMaxDepth(t *testing.T) { } func TestMaxQueryLength(t *testing.T) { - withMaxQueryLen := graphql.MustParseSchema(starwars.Schema, &starwars.Resolver{}, graphql.MaxQueryLength(75)) + withMaxQueryLen := graphql.MustExecutableSchema(starwarsSchemaDef, &starwars.Resolver{}, graphql.MaxQueryLength(75)) gqltesting.RunTests(t, []*gqltesting.Test{ { Schema: withMaxQueryLen,