diff --git a/CHANGELOG.md b/CHANGELOG.md index 9340132a..049b5592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## [Unreleased] + +* [FEATURE] Add resolver field selection inspection helpers (`SelectedFieldNames`, `HasSelectedField`, `SortedSelectedFieldNames`). Helpers are available by default and compute results lazily only when called. An explicit opt-out (`DisableFieldSelections()` schema option) is provided for applications that want to remove even the minimal context insertion overhead when the helpers are never used. + [v1.5.0](https://github.com/graph-gophers/graphql-go/releases/tag/v1.5.0) Release v1.5.0 * [FEATURE] Add specifiedBy directive in #532 diff --git a/README.md b/README.md index 2cfdf6ef..3770d876 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,23 @@ schema := graphql.MustParseSchema(sdl, &RootResolver{}, nil) - `Logger(logger log.Logger)` is used to log panics during query execution. It defaults to `exec.DefaultLogger`. - `PanicHandler(panicHandler errors.PanicHandler)` is used to transform panics into errors during query execution. It defaults to `errors.DefaultPanicHandler`. - `DisableIntrospection()` disables introspection queries. +- `DisableFieldSelections()` disables capturing child field selections used by helper APIs (see below). + +### Field Selection Inspection Helpers + +Resolvers can introspect which immediate child fields were requested using: + +```go +graphql.SelectedFieldNames(ctx) // []string of direct child schema field names +graphql.HasSelectedField(ctx, "name") // bool +graphql.SortedSelectedFieldNames(ctx) // sorted copy +``` + +Use cases include building projection lists for databases or conditionally avoiding expensive sub-fetches. The helpers are intentionally shallow (only direct children) and fragment spreads / inline fragments are flattened with duplicates removed; meta fields (e.g. `__typename`) are excluded. + +Performance: selection data is computed lazily only when a helper is called. If you never call them there is effectively no additional overhead. To remove even the small context value insertion you can opt out with `DisableFieldSelections()`; helpers then return empty results. + +For more detail and examples see the [docs](https://godoc.org/github.com/graph-gophers/graphql-go). ### Custom Errors diff --git a/example_selection2_test.go b/example_selection2_test.go new file mode 100644 index 00000000..7b0c1c80 --- /dev/null +++ b/example_selection2_test.go @@ -0,0 +1,49 @@ +package graphql_test + +import ( + "context" + "fmt" + + "github.com/graph-gophers/graphql-go" +) + +type ( + user2 struct{ id, name, email string } + userResolver2 struct{ u user2 } +) + +func (r *userResolver2) ID() graphql.ID { return graphql.ID(r.u.id) } +func (r *userResolver2) Name() *string { return &r.u.name } +func (r *userResolver2) Email() *string { return &r.u.email } +func (r *userResolver2) Friends(ctx context.Context) []*userResolver2 { return nil } + +type root2 struct{} + +func (r *root2) User(ctx context.Context, args struct{ ID string }) *userResolver2 { + if graphql.HasSelectedField(ctx, "email") { + fmt.Println("email requested") + } + if graphql.HasSelectedField(ctx, "friends") { + fmt.Println("friends requested") + } + return &userResolver2{u: user2{id: args.ID, name: "Alice", email: "a@example.com"}} +} + +// Example_hasSelectedField demonstrates HasSelectedField helper for conditional +// logic without needing the full slice of field names. This can be handy when +// checking for a small number of specific fields (avoids allocating the names +// slice if it hasn't already been built). +func Example_hasSelectedField() { + const s = ` + schema { query: Query } + type Query { user(id: ID!): User } + type User { id: ID! name: String email: String friends: [User!]! } + ` + schema := graphql.MustParseSchema(s, &root2{}) + // Select a subset of fields including a nested composite field; friends requires its own selection set. + query := `query { user(id: "U1") { id email friends { id } } }` + _ = schema.Exec(context.Background(), query, "", nil) + // Output: + // email requested + // friends requested +} diff --git a/example_selection_test.go b/example_selection_test.go new file mode 100644 index 00000000..70047d64 --- /dev/null +++ b/example_selection_test.go @@ -0,0 +1,41 @@ +package graphql_test + +import ( + "context" + "fmt" + + "github.com/graph-gophers/graphql-go" +) + +type ( + user struct{ id, name, email string } + userResolver struct{ u user } +) + +func (r *userResolver) ID() graphql.ID { return graphql.ID(r.u.id) } +func (r *userResolver) Name() *string { return &r.u.name } +func (r *userResolver) Email() *string { return &r.u.email } +func (r *userResolver) Friends(ctx context.Context) []*userResolver { return nil } + +type root struct{} + +func (r *root) User(ctx context.Context, args struct{ ID string }) *userResolver { + fields := graphql.SelectedFieldNames(ctx) + fmt.Println(fields) + return &userResolver{u: user{id: args.ID, name: "Alice", email: "a@example.com"}} +} + +// Example_selectedFieldNames demonstrates SelectedFieldNames usage in a resolver for +// conditional data fetching (e.g. building a DB projection list). +func Example_selectedFieldNames() { + const s = ` + schema { query: Query } + type Query { user(id: ID!): User } + type User { id: ID! name: String email: String friends: [User!]! } + ` + schema := graphql.MustParseSchema(s, &root{}) + query := `query { user(id: "U1") { id name } }` + _ = schema.Exec(context.Background(), query, "", nil) + // Output: + // [id name] +} diff --git a/graphql.go b/graphql.go index dd89a9d7..bcbbada4 100644 --- a/graphql.go +++ b/graphql.go @@ -85,6 +85,7 @@ type Schema struct { useStringDescriptions bool subscribeResolverTimeout time.Duration useFieldResolvers bool + disableFieldSelections bool } // AST returns the abstract syntax tree of the GraphQL schema definition. @@ -121,6 +122,15 @@ func UseFieldResolvers() SchemaOpt { } } +// DisableFieldSelections disables capturing child field selections for the +// SelectedFieldNames / HasSelectedField helpers. When disabled, those helpers +// will always return an empty result / false (i.e. zero-value) and no per-resolver +// 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 } +} + // 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) { @@ -304,10 +314,11 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str Schema: s.schema, AllowIntrospection: s.allowIntrospection == nil || s.allowIntrospection(ctx), // allow introspection by default, i.e. when allowIntrospection is nil }, - Limiter: make(chan struct{}, s.maxParallelism), - Tracer: s.tracer, - Logger: s.logger, - PanicHandler: s.panicHandler, + Limiter: make(chan struct{}, s.maxParallelism), + Tracer: s.tracer, + Logger: s.logger, + PanicHandler: s.panicHandler, + DisableFieldSelections: s.disableFieldSelections, } varTypes := make(map[string]*introspection.Type) for _, v := range op.Vars { diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 45707a2c..58b34f84 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -14,6 +14,7 @@ import ( "github.com/graph-gophers/graphql-go/internal/exec/resolvable" "github.com/graph-gophers/graphql-go/internal/exec/selected" "github.com/graph-gophers/graphql-go/internal/query" + "github.com/graph-gophers/graphql-go/internal/selections" "github.com/graph-gophers/graphql-go/log" "github.com/graph-gophers/graphql-go/trace/tracer" ) @@ -25,6 +26,7 @@ type Request struct { Logger log.Logger PanicHandler errors.PanicHandler SubscribeResolverTimeout time.Duration + DisableFieldSelections bool } func (r *Request) handlePanic(ctx context.Context) { @@ -226,6 +228,9 @@ func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f return errors.Errorf("%s", err) // don't execute any more resolvers if context got cancelled } + if len(f.sels) > 0 && !r.DisableFieldSelections { + ctx = selections.With(ctx, f.sels) + } res, resolverErr := f.resolve(ctx) if resolverErr != nil { err := errors.Errorf("%s", resolverErr) diff --git a/internal/selections/context.go b/internal/selections/context.go new file mode 100644 index 00000000..fd56b3c9 --- /dev/null +++ b/internal/selections/context.go @@ -0,0 +1,107 @@ +// Package selections is for internal use to share selection context between +// the execution engine and the public graphql package without creating an +// import cycle. +// +// The execution layer stores the flattened child selection set for the field +// currently being resolved. The public API converts this into user-friendly +// helpers (SelectedFieldNames, etc.). +package selections + +import ( + "context" + "sync" + + "github.com/graph-gophers/graphql-go/internal/exec/selected" +) + +// ctxKey is an unexported unique type used as context key. +type ctxKey struct{} + +// Lazy holds raw selections and computes the flattened, deduped name list once on demand. +type Lazy struct { + raw []selected.Selection + once sync.Once + names []string + set map[string]struct{} +} + +// Names returns the deduplicated child field names computing them once. +func (l *Lazy) Names() []string { + if l == nil { + return nil + } + l.once.Do(func() { + seen := make(map[string]struct{}, len(l.raw)) + ordered := make([]string, 0, len(l.raw)) + for _, s := range l.raw { + switch s := s.(type) { + case *selected.SchemaField: + name := s.Name + if len(name) >= 2 && name[:2] == "__" { + continue + } + if _, ok := seen[name]; !ok { + seen[name] = struct{}{} + ordered = append(ordered, name) + } + case *selected.TypeAssertion: + collectFromTypeAssertion(&ordered, seen, s.Sels) + case *selected.TypenameField: + continue + } + } + l.names = ordered + l.set = seen + }) + // Return a copy to keep internal slice immutable to callers. + out := make([]string, len(l.names)) + copy(out, l.names) + return out +} + +// Has reports if a field name is in the selection list. +func (l *Lazy) Has(name string) bool { + if l == nil { + return false + } + if l.set == nil { // ensure computed + _ = l.Names() + } + _, ok := l.set[name] + return ok +} + +// collectFromTypeAssertion flattens selections under a type assertion fragment. +func collectFromTypeAssertion(dst *[]string, seen map[string]struct{}, sels []selected.Selection) { + for _, s := range sels { + switch s := s.(type) { + case *selected.SchemaField: + name := s.Name + if len(name) >= 2 && name[:2] == "__" { + continue + } + if _, ok := seen[name]; !ok { + seen[name] = struct{}{} + *dst = append(*dst, name) + } + case *selected.TypeAssertion: + collectFromTypeAssertion(dst, seen, s.Sels) + case *selected.TypenameField: + continue + } + } +} + +// With stores a lazy wrapper for selections in the context. +func With(ctx context.Context, sels []selected.Selection) context.Context { + if len(sels) == 0 { + return ctx + } + return context.WithValue(ctx, ctxKey{}, &Lazy{raw: sels}) +} + +// FromContext retrieves the lazy wrapper (may be nil). +func FromContext(ctx context.Context) *Lazy { + v, _ := ctx.Value(ctxKey{}).(*Lazy) + return v +} diff --git a/selection.go b/selection.go new file mode 100644 index 00000000..f7b03a96 --- /dev/null +++ b/selection.go @@ -0,0 +1,59 @@ +package graphql + +import ( + "context" + "sort" + + "github.com/graph-gophers/graphql-go/internal/selections" +) + +// SelectedFieldNames returns the set of immediate child field names selected +// on the value returned by the current resolver. It returns an empty slice +// when the current field's return type is a leaf (scalar / enum) or when the +// feature was disabled at schema construction via DisableFieldSelections. +// The returned slice is a copy and is safe for the caller to modify. +// +// It is intentionally simple and does not expose the internal AST. If more +// detailed information is needed in the future (e.g. arguments per child, +// nested trees) a separate API can be added without breaking this one. +// +// Notes: +// - Fragment spreads & inline fragments are flattened; the union of all +// possible child fields is returned (deduplicated, preserving first +// appearance order in the query document). +// - Field aliases are ignored; the original schema field names are returned. +// - Meta fields beginning with "__" (including __typename) are excluded. +func SelectedFieldNames(ctx context.Context) []string { + // If no selection info is present (leaf field or no child selections), return empty slice. + lazy := selections.FromContext(ctx) + if lazy == nil { + return []string{} + } + return lazy.Names() +} + +// HasSelectedField returns true if the immediate child selection list contains +// the provided field name (case sensitive). It returns false for leaf return +// types and when DisableFieldSelections was used. +func HasSelectedField(ctx context.Context, name string) bool { + lazy := selections.FromContext(ctx) + if lazy == nil { + return false + } + return lazy.Has(name) +} + +// SortedSelectedFieldNames returns the same data as SelectedFieldNames but +// sorted lexicographically for deterministic ordering scenarios (e.g. cache +// key generation). It will also return an empty slice when selections are +// disabled. +func SortedSelectedFieldNames(ctx context.Context) []string { + names := SelectedFieldNames(ctx) + if len(names) <= 1 { + return names + } + out := make([]string, len(names)) + copy(out, names) + sort.Strings(out) + return out +} diff --git a/selection_bench_test.go b/selection_bench_test.go new file mode 100644 index 00000000..bb3ad3cb --- /dev/null +++ b/selection_bench_test.go @@ -0,0 +1,117 @@ +package graphql_test + +import ( + "context" + "fmt" + "testing" + + graphql "github.com/graph-gophers/graphql-go" +) + +// This benchmark compares query execution when resolvers do NOT call the +// selection helpers vs when they call SelectedFieldNames at object boundaries. +// It documents the lazy overhead of computing child field selections. + +const lazyBenchSchema = ` + schema { query: Query } + type Query { hero: Human } + type Human { id: ID! name: String friends: [Human!]! } + ` + +// Simple in-memory data graph. +type human struct { + id, name string + friends []*human +} + +// Build a small graph once outside the benchmark loops. +var benchHero *human + +func init() { + // Create 5 friends (no recursive friends to keep size stable). + friends := make([]*human, 5) + for i := range friends { + friends[i] = &human{id: fmt.Sprintf("F%d", i), name: "Friend"} + } + benchHero = &human{id: "H1", name: "Hero", friends: friends} +} + +// Baseline resolvers (do NOT invoke selection helpers). +type ( + rootBaseline struct{} + humanResolverBaseline struct{ h *human } +) + +func (r *rootBaseline) Hero(ctx context.Context) *humanResolverBaseline { + return &humanResolverBaseline{h: benchHero} +} +func (h *humanResolverBaseline) ID() graphql.ID { return graphql.ID(h.h.id) } +func (h *humanResolverBaseline) Name() *string { return &h.h.name } +func (h *humanResolverBaseline) Friends(ctx context.Context) []*humanResolverBaseline { + out := make([]*humanResolverBaseline, len(h.h.friends)) + for i, f := range h.h.friends { + out[i] = &humanResolverBaseline{h: f} + } + return out +} + +// Instrumented resolvers (CALL selection helpers once per object-level resolver). +type ( + rootWithSel struct{} + humanResolverWithSel struct{ h *human } +) + +func (r *rootWithSel) Hero(ctx context.Context) *humanResolverWithSel { + // Selection list for hero object (id, name, friends) + _ = graphql.SelectedFieldNames(ctx) + return &humanResolverWithSel{h: benchHero} +} + +func (h *humanResolverWithSel) ID(ctx context.Context) graphql.ID { // leaf: expecting empty slice + return graphql.ID(h.h.id) +} + +func (h *humanResolverWithSel) Name(ctx context.Context) *string { // leaf + return &h.h.name +} + +func (h *humanResolverWithSel) Friends(ctx context.Context) []*humanResolverWithSel { + // Selection list on list field: children of Human inside list items. + _ = graphql.SelectedFieldNames(ctx) + out := make([]*humanResolverWithSel, len(h.h.friends)) + for i, f := range h.h.friends { + // For each friend object we also call once at the object resolver boundary. + out[i] = &humanResolverWithSel{h: f} + } + return out +} + +// Query used for both benchmarks. +const lazyBenchQuery = `query { hero { id name friends { id name } } }` + +func BenchmarkFieldSelections_NoUsage(b *testing.B) { + schema := graphql.MustParseSchema(lazyBenchSchema, &rootBaseline{}) + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + _ = schema.Exec(ctx, lazyBenchQuery, "", nil) + } +} + +func BenchmarkFieldSelections_Disabled_NoUsage(b *testing.B) { + schema := graphql.MustParseSchema(lazyBenchSchema, &rootBaseline{}, graphql.DisableFieldSelections()) + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + _ = schema.Exec(ctx, lazyBenchQuery, "", nil) + } +} + +func BenchmarkFieldSelections_WithSelectedFieldNames(b *testing.B) { + schema := graphql.MustParseSchema(lazyBenchSchema, &rootWithSel{}) + ctx := context.Background() + b.ReportAllocs() + for b.Loop() { + _ = schema.Exec(ctx, lazyBenchQuery, "", nil) + } +} diff --git a/selection_test.go b/selection_test.go new file mode 100644 index 00000000..b6a6ef07 --- /dev/null +++ b/selection_test.go @@ -0,0 +1,164 @@ +package graphql_test + +import ( + "context" + "testing" + + "github.com/graph-gophers/graphql-go" +) + +const selectionTestSchema = ` + schema { query: Query } + type Query { hero: Human } + type Human { id: ID! name: String } +` + +type selectionRoot struct { + t *testing.T + expectNames []string + expectSorted []string + hasChecks map[string]bool +} + +type selectionHuman struct { + t *testing.T + id string + name string +} + +func (r *selectionRoot) Hero(ctx context.Context) *selectionHuman { + names := graphql.SelectedFieldNames(ctx) + sorted := graphql.SortedSelectedFieldNames(ctx) + if !equalStringSlices(names, r.expectNames) { + r.t.Errorf("SelectedFieldNames = %v, want %v", names, r.expectNames) + } + if !equalStringSlices(sorted, r.expectSorted) { + r.t.Errorf("SortedSelectedFieldNames = %v, want %v", sorted, r.expectSorted) + } + for name, want := range r.hasChecks { + if got := graphql.HasSelectedField(ctx, name); got != want { + r.t.Errorf("HasSelectedField(%q) = %v, want %v", name, got, want) + } + } + return &selectionHuman{t: r.t, id: "h1", name: "Luke"} +} + +// Object-level assertions happen in Hero via a wrapper test function; leaf behavior tested here. +func (h *selectionHuman) ID() graphql.ID { return graphql.ID(h.id) } + +func (h *selectionHuman) Name(ctx context.Context) *string { + // Leaf field: should always produce empty selections regardless of enable/disable. + if got := graphql.SelectedFieldNames(ctx); len(got) != 0 { + h.t.Errorf("leaf field SelectedFieldNames = %v, want empty", got) + } + if graphql.HasSelectedField(ctx, "anything") { + h.t.Errorf("leaf field HasSelectedField unexpectedly true") + } + if sorted := graphql.SortedSelectedFieldNames(ctx); len(sorted) != 0 { + h.t.Errorf("leaf field SortedSelectedFieldNames = %v, want empty", sorted) + } + return &h.name +} + +func TestFieldSelectionHelpers(t *testing.T) { + tests := []struct { + name string + schemaOpts []graphql.SchemaOpt + query string + expectNames []string // expected order from SelectedFieldNames at object boundary + expectSorted []string // expected from SortedSelectedFieldNames at object boundary + hasChecks map[string]bool + }{ + { + name: "enabled object order preserved and sorted copy", + query: `query { hero { name id } }`, // order intentionally name,id + expectNames: []string{"name", "id"}, + expectSorted: []string{"id", "name"}, + hasChecks: map[string]bool{"id": true, "name": true, "missing": false}, + }, + { + name: "enabled only one field selected", + query: `query { hero { id } }`, // order intentionally name,id + expectNames: []string{"id"}, + expectSorted: []string{"id"}, + hasChecks: map[string]bool{"id": true, "name": false, "missing": false}, + }, + { + name: "disabled object returns empty", + schemaOpts: []graphql.SchemaOpt{graphql.DisableFieldSelections()}, + query: `query { hero { name id } }`, + expectNames: []string{}, + expectSorted: []string{}, + hasChecks: map[string]bool{"id": false, "name": false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := &selectionRoot{t: t, expectNames: tt.expectNames, expectSorted: tt.expectSorted, hasChecks: tt.hasChecks} + s := graphql.MustParseSchema(selectionTestSchema, root, tt.schemaOpts...) + resp := s.Exec(context.Background(), tt.query, "", nil) + if len(resp.Errors) > 0 { + t.Fatalf("execution errors: %v", resp.Errors) + } + }) + } +} + +func TestSelectedFieldNames_FragmentsAliasesMeta(t *testing.T) { + tests := []struct { + name string + query string + expectNames []string + hasChecks map[string]bool + }{ + { + name: "alias ignored order preserved", + query: `query { hero { idAlias: id name } }`, + expectNames: []string{"id", "name"}, + hasChecks: map[string]bool{"id": true, "idAlias": false, "name": true}, + }, + { + name: "fragment spread flattened", + query: `fragment HFields on Human { id name } query { hero { ...HFields } }`, + expectNames: []string{"id", "name"}, + hasChecks: map[string]bool{"id": true, "name": true}, + }, + { + name: "inline fragment dedup", + query: `query { hero { id ... on Human { id name } } }`, + expectNames: []string{"id", "name"}, + hasChecks: map[string]bool{"id": true, "name": true}, + }, + { + name: "meta field excluded", + query: `query { hero { id __typename name } }`, + expectNames: []string{"id", "name"}, + hasChecks: map[string]bool{"id": true, "name": true, "__typename": false}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := &selectionRoot{t: t, expectNames: tt.expectNames, expectSorted: tt.expectNames, hasChecks: tt.hasChecks} + s := graphql.MustParseSchema(selectionTestSchema, root) + resp := s.Exec(context.Background(), tt.query, "", nil) + if len(resp.Errors) > 0 { + t.Fatalf("execution errors: %v", resp.Errors) + } + }) + } +} + +// equalStringSlices compares content and order. +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}