Skip to content

Commit 29adc43

Browse files
authored
Merge pull request #673 from graph-gophers/selected-fields
feat: selected fields
2 parents ee0376e + af39392 commit 29adc43

File tree

10 files changed

+578
-4
lines changed

10 files changed

+578
-4
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## [Unreleased]
4+
5+
* [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.
6+
37
[v1.5.0](https://github.com/graph-gophers/graphql-go/releases/tag/v1.5.0) Release v1.5.0
48

59
* [FEATURE] Add specifiedBy directive in #532

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,23 @@ schema := graphql.MustParseSchema(sdl, &RootResolver{}, nil)
156156
- `Logger(logger log.Logger)` is used to log panics during query execution. It defaults to `exec.DefaultLogger`.
157157
- `PanicHandler(panicHandler errors.PanicHandler)` is used to transform panics into errors during query execution. It defaults to `errors.DefaultPanicHandler`.
158158
- `DisableIntrospection()` disables introspection queries.
159+
- `DisableFieldSelections()` disables capturing child field selections used by helper APIs (see below).
160+
161+
### Field Selection Inspection Helpers
162+
163+
Resolvers can introspect which immediate child fields were requested using:
164+
165+
```go
166+
graphql.SelectedFieldNames(ctx) // []string of direct child schema field names
167+
graphql.HasSelectedField(ctx, "name") // bool
168+
graphql.SortedSelectedFieldNames(ctx) // sorted copy
169+
```
170+
171+
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.
172+
173+
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.
174+
175+
For more detail and examples see the [docs](https://godoc.org/github.com/graph-gophers/graphql-go).
159176

160177
### Custom Errors
161178

example_selection2_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package graphql_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/graph-gophers/graphql-go"
8+
)
9+
10+
type (
11+
user2 struct{ id, name, email string }
12+
userResolver2 struct{ u user2 }
13+
)
14+
15+
func (r *userResolver2) ID() graphql.ID { return graphql.ID(r.u.id) }
16+
func (r *userResolver2) Name() *string { return &r.u.name }
17+
func (r *userResolver2) Email() *string { return &r.u.email }
18+
func (r *userResolver2) Friends(ctx context.Context) []*userResolver2 { return nil }
19+
20+
type root2 struct{}
21+
22+
func (r *root2) User(ctx context.Context, args struct{ ID string }) *userResolver2 {
23+
if graphql.HasSelectedField(ctx, "email") {
24+
fmt.Println("email requested")
25+
}
26+
if graphql.HasSelectedField(ctx, "friends") {
27+
fmt.Println("friends requested")
28+
}
29+
return &userResolver2{u: user2{id: args.ID, name: "Alice", email: "[email protected]"}}
30+
}
31+
32+
// Example_hasSelectedField demonstrates HasSelectedField helper for conditional
33+
// logic without needing the full slice of field names. This can be handy when
34+
// checking for a small number of specific fields (avoids allocating the names
35+
// slice if it hasn't already been built).
36+
func Example_hasSelectedField() {
37+
const s = `
38+
schema { query: Query }
39+
type Query { user(id: ID!): User }
40+
type User { id: ID! name: String email: String friends: [User!]! }
41+
`
42+
schema := graphql.MustParseSchema(s, &root2{})
43+
// Select a subset of fields including a nested composite field; friends requires its own selection set.
44+
query := `query { user(id: "U1") { id email friends { id } } }`
45+
_ = schema.Exec(context.Background(), query, "", nil)
46+
// Output:
47+
// email requested
48+
// friends requested
49+
}

example_selection_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package graphql_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/graph-gophers/graphql-go"
8+
)
9+
10+
type (
11+
user struct{ id, name, email string }
12+
userResolver struct{ u user }
13+
)
14+
15+
func (r *userResolver) ID() graphql.ID { return graphql.ID(r.u.id) }
16+
func (r *userResolver) Name() *string { return &r.u.name }
17+
func (r *userResolver) Email() *string { return &r.u.email }
18+
func (r *userResolver) Friends(ctx context.Context) []*userResolver { return nil }
19+
20+
type root struct{}
21+
22+
func (r *root) User(ctx context.Context, args struct{ ID string }) *userResolver {
23+
fields := graphql.SelectedFieldNames(ctx)
24+
fmt.Println(fields)
25+
return &userResolver{u: user{id: args.ID, name: "Alice", email: "[email protected]"}}
26+
}
27+
28+
// Example_selectedFieldNames demonstrates SelectedFieldNames usage in a resolver for
29+
// conditional data fetching (e.g. building a DB projection list).
30+
func Example_selectedFieldNames() {
31+
const s = `
32+
schema { query: Query }
33+
type Query { user(id: ID!): User }
34+
type User { id: ID! name: String email: String friends: [User!]! }
35+
`
36+
schema := graphql.MustParseSchema(s, &root{})
37+
query := `query { user(id: "U1") { id name } }`
38+
_ = schema.Exec(context.Background(), query, "", nil)
39+
// Output:
40+
// [id name]
41+
}

graphql.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ type Schema struct {
8585
useStringDescriptions bool
8686
subscribeResolverTimeout time.Duration
8787
useFieldResolvers bool
88+
disableFieldSelections bool
8889
}
8990

9091
// AST returns the abstract syntax tree of the GraphQL schema definition.
@@ -121,6 +122,15 @@ func UseFieldResolvers() SchemaOpt {
121122
}
122123
}
123124

125+
// DisableFieldSelections disables capturing child field selections for the
126+
// SelectedFieldNames / HasSelectedField helpers. When disabled, those helpers
127+
// will always return an empty result / false (i.e. zero-value) and no per-resolver
128+
// selection context is stored. This is an opt-out for applications that never intend
129+
// to use the feature and want to avoid even its small lazy overhead.
130+
func DisableFieldSelections() SchemaOpt {
131+
return func(s *Schema) { s.disableFieldSelections = true }
132+
}
133+
124134
// MaxDepth specifies the maximum field nesting depth in a query. The default is 0 which disables max depth checking.
125135
func MaxDepth(n int) SchemaOpt {
126136
return func(s *Schema) {
@@ -304,10 +314,11 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str
304314
Schema: s.schema,
305315
AllowIntrospection: s.allowIntrospection == nil || s.allowIntrospection(ctx), // allow introspection by default, i.e. when allowIntrospection is nil
306316
},
307-
Limiter: make(chan struct{}, s.maxParallelism),
308-
Tracer: s.tracer,
309-
Logger: s.logger,
310-
PanicHandler: s.panicHandler,
317+
Limiter: make(chan struct{}, s.maxParallelism),
318+
Tracer: s.tracer,
319+
Logger: s.logger,
320+
PanicHandler: s.panicHandler,
321+
DisableFieldSelections: s.disableFieldSelections,
311322
}
312323
varTypes := make(map[string]*introspection.Type)
313324
for _, v := range op.Vars {

internal/exec/exec.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/graph-gophers/graphql-go/internal/exec/resolvable"
1515
"github.com/graph-gophers/graphql-go/internal/exec/selected"
1616
"github.com/graph-gophers/graphql-go/internal/query"
17+
"github.com/graph-gophers/graphql-go/internal/selections"
1718
"github.com/graph-gophers/graphql-go/log"
1819
"github.com/graph-gophers/graphql-go/trace/tracer"
1920
)
@@ -25,6 +26,7 @@ type Request struct {
2526
Logger log.Logger
2627
PanicHandler errors.PanicHandler
2728
SubscribeResolverTimeout time.Duration
29+
DisableFieldSelections bool
2830
}
2931

3032
func (r *Request) handlePanic(ctx context.Context) {
@@ -226,6 +228,9 @@ func execFieldSelection(ctx context.Context, r *Request, s *resolvable.Schema, f
226228
return errors.Errorf("%s", err) // don't execute any more resolvers if context got cancelled
227229
}
228230

231+
if len(f.sels) > 0 && !r.DisableFieldSelections {
232+
ctx = selections.With(ctx, f.sels)
233+
}
229234
res, resolverErr := f.resolve(ctx)
230235
if resolverErr != nil {
231236
err := errors.Errorf("%s", resolverErr)

internal/selections/context.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Package selections is for internal use to share selection context between
2+
// the execution engine and the public graphql package without creating an
3+
// import cycle.
4+
//
5+
// The execution layer stores the flattened child selection set for the field
6+
// currently being resolved. The public API converts this into user-friendly
7+
// helpers (SelectedFieldNames, etc.).
8+
package selections
9+
10+
import (
11+
"context"
12+
"sync"
13+
14+
"github.com/graph-gophers/graphql-go/internal/exec/selected"
15+
)
16+
17+
// ctxKey is an unexported unique type used as context key.
18+
type ctxKey struct{}
19+
20+
// Lazy holds raw selections and computes the flattened, deduped name list once on demand.
21+
type Lazy struct {
22+
raw []selected.Selection
23+
once sync.Once
24+
names []string
25+
set map[string]struct{}
26+
}
27+
28+
// Names returns the deduplicated child field names computing them once.
29+
func (l *Lazy) Names() []string {
30+
if l == nil {
31+
return nil
32+
}
33+
l.once.Do(func() {
34+
seen := make(map[string]struct{}, len(l.raw))
35+
ordered := make([]string, 0, len(l.raw))
36+
for _, s := range l.raw {
37+
switch s := s.(type) {
38+
case *selected.SchemaField:
39+
name := s.Name
40+
if len(name) >= 2 && name[:2] == "__" {
41+
continue
42+
}
43+
if _, ok := seen[name]; !ok {
44+
seen[name] = struct{}{}
45+
ordered = append(ordered, name)
46+
}
47+
case *selected.TypeAssertion:
48+
collectFromTypeAssertion(&ordered, seen, s.Sels)
49+
case *selected.TypenameField:
50+
continue
51+
}
52+
}
53+
l.names = ordered
54+
l.set = seen
55+
})
56+
// Return a copy to keep internal slice immutable to callers.
57+
out := make([]string, len(l.names))
58+
copy(out, l.names)
59+
return out
60+
}
61+
62+
// Has reports if a field name is in the selection list.
63+
func (l *Lazy) Has(name string) bool {
64+
if l == nil {
65+
return false
66+
}
67+
if l.set == nil { // ensure computed
68+
_ = l.Names()
69+
}
70+
_, ok := l.set[name]
71+
return ok
72+
}
73+
74+
// collectFromTypeAssertion flattens selections under a type assertion fragment.
75+
func collectFromTypeAssertion(dst *[]string, seen map[string]struct{}, sels []selected.Selection) {
76+
for _, s := range sels {
77+
switch s := s.(type) {
78+
case *selected.SchemaField:
79+
name := s.Name
80+
if len(name) >= 2 && name[:2] == "__" {
81+
continue
82+
}
83+
if _, ok := seen[name]; !ok {
84+
seen[name] = struct{}{}
85+
*dst = append(*dst, name)
86+
}
87+
case *selected.TypeAssertion:
88+
collectFromTypeAssertion(dst, seen, s.Sels)
89+
case *selected.TypenameField:
90+
continue
91+
}
92+
}
93+
}
94+
95+
// With stores a lazy wrapper for selections in the context.
96+
func With(ctx context.Context, sels []selected.Selection) context.Context {
97+
if len(sels) == 0 {
98+
return ctx
99+
}
100+
return context.WithValue(ctx, ctxKey{}, &Lazy{raw: sels})
101+
}
102+
103+
// FromContext retrieves the lazy wrapper (may be nil).
104+
func FromContext(ctx context.Context) *Lazy {
105+
v, _ := ctx.Value(ctxKey{}).(*Lazy)
106+
return v
107+
}

selection.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package graphql
2+
3+
import (
4+
"context"
5+
"sort"
6+
7+
"github.com/graph-gophers/graphql-go/internal/selections"
8+
)
9+
10+
// SelectedFieldNames returns the set of immediate child field names selected
11+
// on the value returned by the current resolver. It returns an empty slice
12+
// when the current field's return type is a leaf (scalar / enum) or when the
13+
// feature was disabled at schema construction via DisableFieldSelections.
14+
// The returned slice is a copy and is safe for the caller to modify.
15+
//
16+
// It is intentionally simple and does not expose the internal AST. If more
17+
// detailed information is needed in the future (e.g. arguments per child,
18+
// nested trees) a separate API can be added without breaking this one.
19+
//
20+
// Notes:
21+
// - Fragment spreads & inline fragments are flattened; the union of all
22+
// possible child fields is returned (deduplicated, preserving first
23+
// appearance order in the query document).
24+
// - Field aliases are ignored; the original schema field names are returned.
25+
// - Meta fields beginning with "__" (including __typename) are excluded.
26+
func SelectedFieldNames(ctx context.Context) []string {
27+
// If no selection info is present (leaf field or no child selections), return empty slice.
28+
lazy := selections.FromContext(ctx)
29+
if lazy == nil {
30+
return []string{}
31+
}
32+
return lazy.Names()
33+
}
34+
35+
// HasSelectedField returns true if the immediate child selection list contains
36+
// the provided field name (case sensitive). It returns false for leaf return
37+
// types and when DisableFieldSelections was used.
38+
func HasSelectedField(ctx context.Context, name string) bool {
39+
lazy := selections.FromContext(ctx)
40+
if lazy == nil {
41+
return false
42+
}
43+
return lazy.Has(name)
44+
}
45+
46+
// SortedSelectedFieldNames returns the same data as SelectedFieldNames but
47+
// sorted lexicographically for deterministic ordering scenarios (e.g. cache
48+
// key generation). It will also return an empty slice when selections are
49+
// disabled.
50+
func SortedSelectedFieldNames(ctx context.Context) []string {
51+
names := SelectedFieldNames(ctx)
52+
if len(names) <= 1 {
53+
return names
54+
}
55+
out := make([]string, len(names))
56+
copy(out, names)
57+
sort.Strings(out)
58+
return out
59+
}

0 commit comments

Comments
 (0)