Skip to content

Commit 5ef747c

Browse files
authored
Adds safeUnreachable rule
1 parent 3254a67 commit 5ef747c

File tree

5 files changed

+245
-11
lines changed

5 files changed

+245
-11
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- **core**: BREAKING CHANGE - `diff` is now async
66
- **github**: BREAKING CHANGE - `experimental_merge` is now enabled by default
77
- **core**: Adds `considerUsage` rule
8+
- **core**: Adds `safeUnreachable` rule
89
- **core**: Fixes missing names of default root types
910
- **cli**, **ci**: Adds `@aws_lambda` directive
1011
- **cli**, **ci**: Fixes missing headers in diff command
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {buildSchema} from 'graphql';
2+
3+
import {diff, CriticalityLevel} from '../../../src/index';
4+
import {findFirstChangeByPath} from '../../../utils/testing';
5+
import {safeUnreachable} from '../../../src/diff/rules/safe-unreachable';
6+
7+
describe('safeUnreachable rule', () => {
8+
test('removed field', async () => {
9+
const a = buildSchema(/* GraphQL */ `
10+
type Foo {
11+
a: String!
12+
}
13+
14+
type Bar {
15+
a: String
16+
b: String
17+
}
18+
`);
19+
const b = buildSchema(/* GraphQL */ `
20+
type Foo {
21+
a: String!
22+
}
23+
24+
type Bar {
25+
a: String
26+
}
27+
`);
28+
29+
const changes = await diff(a, b, [safeUnreachable]);
30+
const removed = findFirstChangeByPath(changes, 'Bar.b');
31+
32+
expect(removed.criticality.level).toBe(CriticalityLevel.NonBreaking);
33+
expect(removed.message).toContain(`Unreachable from root`);
34+
});
35+
36+
test('removed type', async () => {
37+
const a = buildSchema(/* GraphQL */ `
38+
type Query {
39+
bar: String!
40+
}
41+
42+
type Foo {
43+
a: String!
44+
}
45+
`);
46+
const b = buildSchema(/* GraphQL */ `
47+
type Query {
48+
bar: String!
49+
}
50+
`);
51+
52+
const changes = await diff(a, b, [safeUnreachable]);
53+
const removed = findFirstChangeByPath(changes, 'Foo');
54+
55+
expect(removed.criticality.level).toBe(CriticalityLevel.NonBreaking);
56+
expect(removed.message).toContain(`Unreachable from root`);
57+
});
58+
59+
test('removed scalar', async () => {
60+
const a = buildSchema(/* GraphQL */ `
61+
type Query {
62+
bar: String!
63+
}
64+
65+
scalar JSON
66+
`);
67+
const b = buildSchema(/* GraphQL */ `
68+
type Query {
69+
bar: String!
70+
}
71+
`);
72+
73+
const changes = await diff(a, b, [safeUnreachable]);
74+
const removed = findFirstChangeByPath(changes, 'JSON');
75+
76+
expect(removed.criticality.level).toBe(CriticalityLevel.NonBreaking);
77+
expect(removed.message).toContain(`Unreachable from root`);
78+
});
79+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {CriticalityLevel} from './../changes/change';
2+
import {Rule} from './types';
3+
import {parsePath} from '../../utils/path';
4+
import {getReachableTypes} from '../../utils/graphql';
5+
6+
export const safeUnreachable: Rule = ({changes, oldSchema}) => {
7+
const reachable = getReachableTypes(oldSchema);
8+
9+
return changes.map((change) => {
10+
if (change.criticality.level === CriticalityLevel.Breaking && change.path) {
11+
const [typeName] = parsePath(change.path);
12+
13+
if (!reachable.has(typeName)) {
14+
return {
15+
...change,
16+
criticality: {
17+
...change.criticality,
18+
level: CriticalityLevel.NonBreaking,
19+
},
20+
message: "Unreachable from root"
21+
};
22+
}
23+
}
24+
25+
return change;
26+
});
27+
};

packages/core/src/utils/graphql.ts

Lines changed: 117 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ import {
1515
visitWithTypeInfo,
1616
getNamedType,
1717
FieldNode,
18+
isInterfaceType,
19+
isScalarType,
20+
isObjectType,
21+
isUnionType,
22+
isInputObjectType,
23+
GraphQLScalarType,
24+
GraphQLInterfaceType,
25+
GraphQLObjectType,
26+
GraphQLUnionType,
27+
GraphQLEnumType,
28+
GraphQLInputObjectType,
1829
} from 'graphql';
1930

2031
export function safeChangeForField(
@@ -119,19 +130,19 @@ export function findDeprecatedUsages(
119130
Argument(node) {
120131
const argument = typeInfo.getArgument();
121132
if (argument) {
122-
const reason = argument.deprecationReason;
123-
if (reason) {
124-
const fieldDef = typeInfo.getFieldDef();
125-
if (fieldDef) {
126-
errors.push(
127-
new GraphQLError(
128-
`The argument '${argument?.name}' of '${fieldDef.name}' is deprecated. ${reason}`,
129-
[node],
130-
),
131-
);
133+
const reason = argument.deprecationReason;
134+
if (reason) {
135+
const fieldDef = typeInfo.getFieldDef();
136+
if (fieldDef) {
137+
errors.push(
138+
new GraphQLError(
139+
`The argument '${argument?.name}' of '${fieldDef.name}' is deprecated. ${reason}`,
140+
[node],
141+
),
142+
);
143+
}
132144
}
133145
}
134-
}
135146
},
136147
Field(node) {
137148
const fieldDef = typeInfo.getFieldDef();
@@ -203,3 +214,98 @@ export function removeDirectives(
203214

204215
return node;
205216
}
217+
218+
export function getReachableTypes(schema: GraphQLSchema): Set<string> {
219+
const reachableTypes = new Set<string>();
220+
221+
const collect = (type: GraphQLNamedType): false | void => {
222+
const typeName = type.name;
223+
224+
if (reachableTypes.has(typeName)) {
225+
return;
226+
}
227+
228+
reachableTypes.add(typeName);
229+
230+
if (isScalarType(type)) {
231+
return;
232+
} else if (isInterfaceType(type) || isObjectType(type)) {
233+
if (isInterfaceType(type)) {
234+
const {objects, interfaces} = schema.getImplementations(type);
235+
236+
for (const child of objects) {
237+
collect(child);
238+
}
239+
240+
for (const child of interfaces) {
241+
collect(child);
242+
}
243+
}
244+
245+
const fields = type.getFields();
246+
247+
for (const fieldName in fields) {
248+
const field = fields[fieldName];
249+
250+
collect(resolveOutputType(field.type));
251+
252+
const args = field.args;
253+
254+
for (const argName in args) {
255+
const arg = args[argName];
256+
257+
collect(resolveInputType(arg.type));
258+
}
259+
}
260+
} else if (isUnionType(type)) {
261+
const types = type.getTypes();
262+
for (const child of types) {
263+
collect(child);
264+
}
265+
} else if (isInputObjectType(type)) {
266+
const fields = type.getFields();
267+
for (const fieldName in fields) {
268+
const field = fields[fieldName];
269+
270+
collect(resolveInputType(field.type));
271+
}
272+
}
273+
};
274+
275+
for (const type of [
276+
schema.getQueryType(),
277+
schema.getMutationType(),
278+
schema.getSubscriptionType(),
279+
]) {
280+
if (type) {
281+
collect(type);
282+
}
283+
}
284+
285+
return reachableTypes;
286+
}
287+
288+
function resolveOutputType(
289+
output: GraphQLOutputType,
290+
):
291+
| GraphQLScalarType
292+
| GraphQLObjectType
293+
| GraphQLInterfaceType
294+
| GraphQLUnionType
295+
| GraphQLEnumType {
296+
if (isListType(output) || isNonNullType(output)) {
297+
return resolveOutputType(output.ofType);
298+
}
299+
300+
return output;
301+
}
302+
303+
function resolveInputType(
304+
input: GraphQLInputType,
305+
): GraphQLScalarType | GraphQLEnumType | GraphQLInputObjectType {
306+
if (isListType(input) || isNonNullType(input)) {
307+
return resolveInputType(input.ofType);
308+
}
309+
310+
return input;
311+
}

website/docs/essentials/diff.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,27 @@ Changes of descriptions are filtered out and are not displayed in the CLI result
9696

9797
graphql-inspector diff https://api.com/graphql schema.graphql --rule ignoreDescriptionChanges
9898

99+
**safeUnreachable**
100+
101+
Breaking changes done on unreachable parts of schema (non-accessible when starting from the root types) won't be marked as breaking.
102+
103+
graphql-inspector diff https://api.com/graphql schema.graphql --rule safeUnreachable
104+
105+
Example of unreachable type:
106+
107+
```graphql
108+
type Query {
109+
me: String
110+
}
111+
112+
"""
113+
User can't be requested, it's unreachable
114+
"""
115+
type User {
116+
id: ID!
117+
}
118+
```
119+
99120
**considerUsage**
100121

101122
Decides if a breaking change are in fact breaking, based on real usage of schema.

0 commit comments

Comments
 (0)