Skip to content

Commit cc5ee7f

Browse files
feature: Adds UniqueDirectivesPerLocationRule
1 parent 5c29cc9 commit cc5ee7f

File tree

3 files changed

+270
-1
lines changed

3 files changed

+270
-1
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
2+
/**
3+
* Unique directive names per location
4+
*
5+
* A GraphQL document is only valid if all non-repeatable directives at
6+
* a given location are uniquely named.
7+
*
8+
* See https://spec.graphql.org/draft/#sec-Directives-Are-Unique-Per-Location
9+
*/
10+
func UniqueDirectivesPerLocationRule(context: ValidationContext) -> Visitor {
11+
var uniqueDirectiveMap = [String: Bool]()
12+
13+
let schema = context.schema
14+
let definedDirectives = schema.directives
15+
for directive in definedDirectives {
16+
uniqueDirectiveMap[directive.name] = !directive.isRepeatable
17+
}
18+
19+
let astDefinitions = context.ast.definitions
20+
for def in astDefinitions {
21+
if let directive = def as? DirectiveDefinition {
22+
uniqueDirectiveMap[directive.name.value] = !directive.repeatable
23+
}
24+
}
25+
26+
let schemaDirectives = [String: Directive]()
27+
var typeDirectivesMap = [String: [String: Directive]]()
28+
29+
return Visitor(
30+
enter: { node, _, _, _, _ in
31+
// if let operation = node as? OperationDefinition {
32+
// Many different AST nodes may contain directives. Rather than listing
33+
// them all, just listen for entering any node, and check to see if it
34+
// defines any directives.
35+
if
36+
let directiveNodeResult = node.get(key: "directives"),
37+
case let .array(directiveNodes) = directiveNodeResult,
38+
let directives = directiveNodes as? [Directive]
39+
{
40+
var seenDirectives = [String: Directive]()
41+
if node.kind == .schemaDefinition || node.kind == .schemaExtensionDefinition {
42+
seenDirectives = schemaDirectives
43+
} else if let node = node as? TypeDefinition {
44+
let typeName = node.name.value
45+
seenDirectives = typeDirectivesMap[typeName] ?? [:]
46+
typeDirectivesMap[typeName] = seenDirectives
47+
} else if let node = node as? TypeExtensionDefinition {
48+
let typeName = node.definition.name.value
49+
seenDirectives = typeDirectivesMap[typeName] ?? [:]
50+
typeDirectivesMap[typeName] = seenDirectives
51+
}
52+
53+
for directive in directives {
54+
let directiveName = directive.name.value
55+
56+
if uniqueDirectiveMap[directiveName] ?? false {
57+
if let seenDirective = seenDirectives[directiveName] {
58+
context.report(
59+
error: GraphQLError(
60+
message: "The directive \"@\(directiveName)\" can only be used once at this location.",
61+
nodes: [seenDirective, directive]
62+
)
63+
)
64+
} else {
65+
seenDirectives[directiveName] = directive
66+
}
67+
}
68+
}
69+
}
70+
return .continue
71+
}
72+
)
73+
}

Sources/GraphQL/Validation/SpecifiedRules.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public let specifiedRules: [(ValidationContext) -> Visitor] = [
2020
NoUndefinedVariablesRule,
2121
NoUnusedVariablesRule,
2222
KnownDirectivesRule,
23-
// UniqueDirectivesPerLocationRule,
23+
UniqueDirectivesPerLocationRule,
2424
// DeferStreamDirectiveOnRootFieldRule,
2525
// DeferStreamDirectiveOnValidOperationsRule,
2626
// DeferStreamDirectiveLabelRule,
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
@testable import GraphQL
2+
import XCTest
3+
4+
class UniqueDirectivesPerLocationRuleTests: ValidationTestCase {
5+
override func setUp() {
6+
rule = UniqueDirectivesPerLocationRule
7+
}
8+
9+
func testNoDirectives() throws {
10+
try assertValid(
11+
"""
12+
fragment Test on Type {
13+
field
14+
}
15+
""",
16+
schema: schema
17+
)
18+
}
19+
20+
func testUniqueDirectivesInDifferentLocations() throws {
21+
try assertValid(
22+
"""
23+
fragment Test on Type @directiveA {
24+
field @directiveB
25+
}
26+
""",
27+
schema: schema
28+
)
29+
}
30+
31+
func testUniqueDirectivesInSameLocation() throws {
32+
try assertValid(
33+
"""
34+
fragment Test on Type @directiveA @directiveB {
35+
field @directiveA @directiveB
36+
}
37+
""",
38+
schema: schema
39+
)
40+
}
41+
42+
func testSameDirectivesInDifferentLocations() throws {
43+
try assertValid(
44+
"""
45+
fragment Test on Type @directiveA {
46+
field @directiveA
47+
}
48+
""",
49+
schema: schema
50+
)
51+
}
52+
53+
func testSameDirectivesInSimilarLocations() throws {
54+
try assertValid(
55+
"""
56+
fragment Test on Type {
57+
field @directive
58+
field @directive
59+
}
60+
""",
61+
schema: schema
62+
)
63+
}
64+
65+
func testRepeatableDirectivesInSameLocation() throws {
66+
try assertValid(
67+
"""
68+
fragment Test on Type @repeatable @repeatable {
69+
field @repeatable @repeatable
70+
}
71+
""",
72+
schema: schema
73+
)
74+
}
75+
76+
func testUnknownDirectivesMustBeIgnored() throws {
77+
try assertValid(
78+
"""
79+
type Test @unknown @unknown {
80+
field: String! @unknown @unknown
81+
}
82+
83+
extend type Test @unknown {
84+
anotherField: String!
85+
}
86+
""",
87+
schema: schema
88+
)
89+
}
90+
91+
func testDuplicateDirectivesInOneLocation() throws {
92+
let errors = try assertInvalid(
93+
errorCount: 1,
94+
query:
95+
"""
96+
fragment Test on Type {
97+
field @directive @directive
98+
}
99+
""",
100+
schema: schema
101+
)
102+
try assertValidationError(
103+
error: errors[0],
104+
locations: [
105+
(line: 2, column: 9),
106+
(line: 2, column: 20),
107+
],
108+
message: #"The directive "@directive" can only be used once at this location."#
109+
)
110+
}
111+
112+
func testManyDuplicateDirectivesInOneLocation() throws {
113+
let errors = try assertInvalid(
114+
errorCount: 2,
115+
query:
116+
"""
117+
fragment Test on Type {
118+
field @directive @directive @directive
119+
}
120+
""",
121+
schema: schema
122+
)
123+
try assertValidationError(
124+
error: errors[0],
125+
locations: [
126+
(line: 2, column: 9),
127+
(line: 2, column: 20),
128+
],
129+
message: #"The directive "@directive" can only be used once at this location."#
130+
)
131+
try assertValidationError(
132+
error: errors[1],
133+
locations: [
134+
(line: 2, column: 9),
135+
(line: 2, column: 31),
136+
],
137+
message: #"The directive "@directive" can only be used once at this location."#
138+
)
139+
}
140+
141+
func testDifferentDuplicateDirectivesInOneLocation() throws {
142+
let errors = try assertInvalid(
143+
errorCount: 2,
144+
query:
145+
"""
146+
fragment Test on Type {
147+
field @directiveA @directiveB @directiveA @directiveB
148+
}
149+
""",
150+
schema: schema
151+
)
152+
try assertValidationError(
153+
error: errors[0],
154+
locations: [
155+
(line: 2, column: 9),
156+
(line: 2, column: 33),
157+
],
158+
message: #"The directive "@directiveA" can only be used once at this location."#
159+
)
160+
try assertValidationError(
161+
error: errors[1],
162+
locations: [
163+
(line: 2, column: 21),
164+
(line: 2, column: 45),
165+
],
166+
message: #"The directive "@directiveB" can only be used once at this location."#
167+
)
168+
}
169+
170+
// TODO: Add SDL tests
171+
172+
let schema = try! GraphQLSchema(
173+
query: ValidationExampleQueryRoot,
174+
types: [
175+
ValidationExampleCat,
176+
ValidationExampleDog,
177+
ValidationExampleHuman,
178+
ValidationExampleAlien,
179+
],
180+
directives: {
181+
var directives = specifiedDirectives
182+
directives.append(contentsOf: [
183+
ValidationFieldDirective,
184+
try! GraphQLDirective(name: "directive", locations: [.field, .fragmentDefinition]),
185+
try! GraphQLDirective(name: "directiveA", locations: [.field, .fragmentDefinition]),
186+
try! GraphQLDirective(name: "directiveB", locations: [.field, .fragmentDefinition]),
187+
try! GraphQLDirective(
188+
name: "repeatable",
189+
locations: [.field, .fragmentDefinition],
190+
isRepeatable: true
191+
),
192+
])
193+
return directives
194+
}()
195+
)
196+
}

0 commit comments

Comments
 (0)