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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
49 changes: 49 additions & 0 deletions example_selection2_test.go
Original file line number Diff line number Diff line change
@@ -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: "[email protected]"}}
}

// 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
}
41 changes: 41 additions & 0 deletions example_selection_test.go
Original file line number Diff line number Diff line change
@@ -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: "[email protected]"}}
}

// 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]
}
19 changes: 15 additions & 4 deletions graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions internal/exec/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
107 changes: 107 additions & 0 deletions internal/selections/context.go
Original file line number Diff line number Diff line change
@@ -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
}
59 changes: 59 additions & 0 deletions selection.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading