Skip to content

Commit fce271c

Browse files
authored
Merge pull request #756 from graph-gophers/validate-deprecated
feat: validate deprecated fields
2 parents 132a487 + e2ef73f commit fce271c

9 files changed

Lines changed: 97 additions & 31 deletions

CHANGELOG.md

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

3+
* [FEATURE] Add `ValidateDeprecated()` schema option to enable validation of deprecated fields, arguments (including directive arguments), input fields, and enum values in queries. When enabled, usage of deprecated schema elements results in validation errors. This opt-in approach allows applications to enforce deprecation policies without breaking existing clients.
4+
35
* [FEATURE] Support executable-document description strings on full-form operations, fragments, and variable definitions. Descriptions remain non-semantic and do not change validation or execution behavior. Executable `#` comments remain ignored.
46

57
[v1.9.0](https://github.com/graph-gophers/graphql-go/releases/tag/v1.9.0) Release v1.9.0

example_deprecated_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package graphql_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
9+
graphql "github.com/graph-gophers/graphql-go"
10+
)
11+
12+
type testValidateDeprecatedResolver struct{}
13+
14+
func (r *testValidateDeprecatedResolver) DeprecatedField() string {
15+
return "old value"
16+
}
17+
18+
func Example_validateDeprecated() {
19+
const sdl = `
20+
schema {
21+
query: Query
22+
}
23+
type Query {
24+
deprecatedField: String! @deprecated(reason: "Use replacementField")
25+
}
26+
`
27+
28+
schema := graphql.MustParseSchema(sdl, &testValidateDeprecatedResolver{})
29+
res := schema.Exec(context.Background(), `{ deprecatedField }`, "", nil)
30+
fmt.Println("Without validation:")
31+
_ = json.NewEncoder(os.Stdout).Encode(res)
32+
33+
clone := schema.MustClone(&testValidateDeprecatedResolver{}, graphql.ValidateDeprecated())
34+
res = clone.Exec(context.Background(), `{ deprecatedField }`, "", nil)
35+
fmt.Println("With validation:")
36+
_ = json.NewEncoder(os.Stdout).Encode(res)
37+
38+
// Output:
39+
// Without validation:
40+
// {"data":{"deprecatedField":"old value"}}
41+
// With validation:
42+
// {"errors":[{"message":"The field Query.deprecatedField is deprecated. Use replacementField","locations":[{"line":1,"column":3}]}]}
43+
}

graphql.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ type Schema struct {
194194
disableMemoryPooling bool
195195
maxPooledBufferCapacity int
196196
overlapPairLimit int
197+
validateDeprecated bool
197198
}
198199

199200
// AST returns the abstract syntax tree of the GraphQL schema definition.
@@ -284,6 +285,14 @@ func OverlapValidationLimit(n int) SchemaOpt {
284285
return func(s *Schema) { s.overlapPairLimit = n }
285286
}
286287

288+
// ValidateDeprecated enables validation errors when a query uses deprecated fields, arguments,
289+
// enum values, or input fields (rule: NoDeprecatedCustomRule). By default, querying deprecated
290+
// schema elements is allowed and produces no errors, matching the behaviour of the graphql-js
291+
// reference implementation where NoDeprecatedCustomRule is an opt-in custom rule.
292+
func ValidateDeprecated() SchemaOpt {
293+
return func(s *Schema) { s.validateDeprecated = true }
294+
}
295+
287296
// Tracer is used to trace queries and fields. It defaults to [noop.Tracer].
288297
func Tracer(t tracer.Tracer) SchemaOpt {
289298
return func(s *Schema) {
@@ -379,7 +388,7 @@ func (s *Schema) ValidateWithVariables(queryString string, variables map[string]
379388
return []*errors.QueryError{errors.Errorf("executable document must contain at least one operation")}
380389
}
381390

382-
return validation.Validate(s.schema, doc, variables, s.maxDepth, s.overlapPairLimit)
391+
return validation.Validate(s.schema, doc, variables, s.maxDepth, s.overlapPairLimit, s.validateDeprecated)
383392
}
384393

385394
// Exec executes the given query with the schema's resolver. It panics if the schema was created
@@ -402,7 +411,7 @@ func (s *Schema) exec(ctx context.Context, queryString string, operationName str
402411
}
403412

404413
validationFinish := s.validationTracer.TraceValidation(ctx)
405-
errs := validation.Validate(s.schema, doc, variables, s.maxDepth, s.overlapPairLimit)
414+
errs := validation.Validate(s.schema, doc, variables, s.maxDepth, s.overlapPairLimit, s.validateDeprecated)
406415
validationFinish(errs)
407416
if len(errs) != 0 {
408417
return &Response{Errors: errs}

internal/validation/overlap_fuzz_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func FuzzValidateOverlapMixed(f *testing.F) {
7171
return
7272
}
7373
// Use overlap limit to bound cost.
74-
errs := v.Validate(s, doc, nil, 0, 10_000)
74+
errs := v.Validate(s, doc, nil, 0, 10_000, false)
7575
// Ensure no panic (implicit). Optionally sanity check: errors slice must not be ridiculously huge.
7676
if len(errs) > 1000 {
7777
t.Fatalf("too many errors: %d", len(errs))

internal/validation/validate_max_depth_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func (tc maxDepthTestCase) Run(t *testing.T, s *ast.Schema) {
8383
t.Fatal(qErr)
8484
}
8585

86-
errs := Validate(s, doc, nil, tc.depth, 0)
86+
errs := Validate(s, doc, nil, tc.depth, 0, false)
8787
if len(tc.expectedErrors) > 0 {
8888
if len(errs) > 0 {
8989
for _, expected := range tc.expectedErrors {
@@ -489,7 +489,7 @@ func TestMaxDepthValidation(t *testing.T) {
489489
t.Fatal(err)
490490
}
491491

492-
context := newContext(s, doc, tc.maxDepth, 0)
492+
context := newContext(s, doc, tc.maxDepth, 0, false)
493493
op := doc.Operations[0]
494494

495495
opc := &opContext{context: context, ops: doc.Operations}

internal/validation/validation.go

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type context struct {
4343
overlapPairLimit int
4444
overlapPairsObserved int
4545
overlapLimitHit bool
46+
validateDeprecated bool
4647
}
4748

4849
func (c *context) addErr(loc errors.Location, rule string, format string, a ...any) {
@@ -62,21 +63,22 @@ type opContext struct {
6263
ops []*ast.OperationDefinition
6364
}
6465

65-
func newContext(s *ast.Schema, doc *ast.ExecutableDefinition, maxDepth int, overlapPairLimit int) *context {
66+
func newContext(s *ast.Schema, doc *ast.ExecutableDefinition, maxDepth int, overlapPairLimit int, validateDeprecated bool) *context {
6667
return &context{
67-
schema: s,
68-
doc: doc,
69-
opErrs: make(map[*ast.OperationDefinition][]*errors.QueryError),
70-
usedVars: make(map[*ast.OperationDefinition]varSet),
71-
fieldMap: make(map[*ast.Field]fieldInfo),
72-
overlapValidated: make(map[selectionPair]bool),
73-
maxDepth: maxDepth,
74-
overlapPairLimit: overlapPairLimit,
68+
schema: s,
69+
doc: doc,
70+
opErrs: make(map[*ast.OperationDefinition][]*errors.QueryError),
71+
usedVars: make(map[*ast.OperationDefinition]varSet),
72+
fieldMap: make(map[*ast.Field]fieldInfo),
73+
overlapValidated: make(map[selectionPair]bool),
74+
maxDepth: maxDepth,
75+
overlapPairLimit: overlapPairLimit,
76+
validateDeprecated: validateDeprecated,
7577
}
7678
}
7779

78-
func Validate(s *ast.Schema, doc *ast.ExecutableDefinition, variables map[string]any, maxDepth int, overlapPairLimit int) []*errors.QueryError {
79-
c := newContext(s, doc, maxDepth, overlapPairLimit)
80+
func Validate(s *ast.Schema, doc *ast.ExecutableDefinition, variables map[string]any, maxDepth int, overlapPairLimit int, validateDeprecated bool) []*errors.QueryError {
81+
c := newContext(s, doc, maxDepth, overlapPairLimit, validateDeprecated)
8082

8183
opNames := make(nameSet, len(doc.Operations))
8284
fragUsedBy := make(map[*ast.FragmentDefinition][]*ast.OperationDefinition)
@@ -628,8 +630,10 @@ func validateSelection(c *opContext, sel ast.Selection, t ast.NamedType) {
628630

629631
validateArgumentLiterals(c, sel.Arguments)
630632
if f != nil {
631-
if reason, ok := deprecatedReason(f.Directives); ok && t != nil {
632-
c.addErr(sel.Name.Loc, "NoDeprecatedCustomRule", "The field %s.%s is deprecated. %s", t.TypeName(), fieldName, reason)
633+
if c.validateDeprecated {
634+
if reason, ok := deprecatedReason(f.Directives); ok && t != nil {
635+
c.addErr(sel.Name.Loc, "NoDeprecatedCustomRule", "The field %s.%s is deprecated. %s", t.TypeName(), fieldName, reason)
636+
}
633637
}
634638

635639
validateArgumentTypes(c, sel.Arguments, f.Arguments, sel.Alias.Loc,
@@ -643,8 +647,10 @@ func validateSelection(c *opContext, sel ast.Selection, t ast.NamedType) {
643647
if argDecl == nil {
644648
continue
645649
}
646-
if reason, ok := deprecatedReason(argDecl.Directives); ok {
647-
c.addErr(selArg.Name.Loc, "NoDeprecatedCustomRule", "Field %q argument %q is deprecated. %s", t.TypeName()+"."+fieldName, selArg.Name.Name, reason)
650+
if c.validateDeprecated {
651+
if reason, ok := deprecatedReason(argDecl.Directives); ok {
652+
c.addErr(selArg.Name.Loc, "NoDeprecatedCustomRule", "Field %q argument %q is deprecated. %s", t.TypeName()+"."+fieldName, selArg.Name.Name, reason)
653+
}
648654
}
649655
}
650656

@@ -658,8 +664,10 @@ func validateSelection(c *opContext, sel ast.Selection, t ast.NamedType) {
658664
if argDecl == nil {
659665
continue
660666
}
661-
if reason, ok := deprecatedReason(argDecl.Directives); ok {
662-
c.addErr(selArg.Name.Loc, "NoDeprecatedCustomRule", "Directive %q argument %q is deprecated. %s", "@"+directive.Name.Name, selArg.Name.Name, reason)
667+
if c.validateDeprecated {
668+
if reason, ok := deprecatedReason(argDecl.Directives); ok {
669+
c.addErr(selArg.Name.Loc, "NoDeprecatedCustomRule", "Directive %q argument %q is deprecated. %s", "@"+directive.Name.Name, selArg.Name.Name, reason)
670+
}
663671
}
664672
}
665673
}
@@ -1078,8 +1086,10 @@ func validateDirectives(c *opContext, loc string, directives ast.DirectiveList)
10781086
if argDecl == nil {
10791087
continue
10801088
}
1081-
if reason, ok := deprecatedReason(argDecl.Directives); ok {
1082-
c.addErr(selArg.Name.Loc, "NoDeprecatedCustomRule", "Directive %q argument %q is deprecated. %s", "@"+dirName, selArg.Name.Name, reason)
1089+
if c.validateDeprecated {
1090+
if reason, ok := deprecatedReason(argDecl.Directives); ok {
1091+
c.addErr(selArg.Name.Loc, "NoDeprecatedCustomRule", "Directive %q argument %q is deprecated. %s", "@"+dirName, selArg.Name.Name, reason)
1092+
}
10831093
}
10841094
}
10851095
}
@@ -1267,8 +1277,10 @@ func validateValueType(c *opContext, v ast.Value, t ast.Type) (bool, errors.Loca
12671277
if option.EnumValue != lit.Text {
12681278
continue
12691279
}
1270-
if depReason, deprecated := deprecatedReason(option.Directives); deprecated {
1271-
c.addErr(lit.Location(), "NoDeprecatedCustomRule", "The enum value %q is deprecated. %s", enumType.Name+"."+option.EnumValue, depReason)
1280+
if c.validateDeprecated {
1281+
if depReason, deprecated := deprecatedReason(option.Directives); deprecated {
1282+
c.addErr(lit.Location(), "NoDeprecatedCustomRule", "The enum value %q is deprecated. %s", enumType.Name+"."+option.EnumValue, depReason)
1283+
}
12721284
}
12731285
break
12741286
}
@@ -1302,7 +1314,7 @@ func validateValueType(c *opContext, v ast.Value, t ast.Type) (bool, errors.Loca
13021314
suggestion := makeSuggestion("Did you mean", t.Values.Names(), name)
13031315
return false, f.Name.Loc, fmt.Sprintf("Field %q is not defined by type %q.%s", name, t.Name, suggestion)
13041316
}
1305-
if depReason, deprecated := deprecatedReason(iv.Directives); deprecated {
1317+
if depReason, deprecated := deprecatedReason(iv.Directives); deprecated && c.validateDeprecated {
13061318
c.addErr(f.Name.Loc, "NoDeprecatedCustomRule", "The input field %s.%s is deprecated. %s", t.Name, iv.Name.Name, depReason)
13071319
}
13081320
if ok, errLoc, reason := validateValueType(c, f.Value, iv.Type); !ok {

internal/validation/validation_bench_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func BenchmarkValidate(b *testing.B) {
6565
b.Run(tc.name, func(b *testing.B) {
6666
b.ReportAllocs()
6767
for b.Loop() {
68-
benchErrs = validation.Validate(s, doc, nil, 0, 0)
68+
benchErrs = validation.Validate(s, doc, nil, 0, 0, false)
6969
}
7070
})
7171
}
@@ -91,7 +91,7 @@ func BenchmarkValidateWorstCaseAliasCollision(b *testing.B) {
9191
b.Run("alias-collision-"+strconv.Itoa(n), func(b *testing.B) {
9292
b.ReportAllocs()
9393
for b.Loop() {
94-
benchErrs = validation.Validate(s, doc, nil, 0, 0)
94+
benchErrs = validation.Validate(s, doc, nil, 0, 0, false)
9595
}
9696

9797
if b.N > 0 && expectedPairs > 0 {

internal/validation/validation_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ func TestValidate(t *testing.T) {
8282
if err != nil {
8383
t.Fatalf("failed to parse query: %s", err)
8484
}
85-
errs := validation.Validate(schemas[test.Schema], d, test.Vars, 0, 0)
85+
errs := validation.Validate(schemas[test.Schema], d, test.Vars, 0, 0, test.Rule == "NoDeprecatedCustomRule")
8686
got := []*errors.QueryError{}
8787
for _, err := range errs {
8888
if err.Rule == test.Rule {

subscriptions.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func (s *Schema) subscribe(ctx context.Context, queryString string, operationNam
4040
}
4141

4242
validationFinish := s.validationTracer.TraceValidation(ctx)
43-
errs := validation.Validate(s.schema, doc, variables, s.maxDepth, s.overlapPairLimit)
43+
errs := validation.Validate(s.schema, doc, variables, s.maxDepth, s.overlapPairLimit, s.validateDeprecated)
4444
validationFinish(errs)
4545
if len(errs) != 0 {
4646
return sendAndReturnClosed(&Response{Errors: errs})

0 commit comments

Comments
 (0)