Skip to content

Commit 1170e5f

Browse files
committed
feat(core): add escapeSingleQuotes option for enum change messages
1 parent 2d22d32 commit 1170e5f

File tree

9 files changed

+203
-28
lines changed

9 files changed

+203
-28
lines changed

.changeset/large-walls-wash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-inspector/core': minor
3+
---
4+
5+
Add escapeSingleQuotes option for enum change messages

packages/core/__tests__/diff/enum.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,4 +274,105 @@ describe('enum', () => {
274274
expect(change.criticality.reason).toBeDefined();
275275
expect(change.message).toEqual(`Enum value 'C' was added to enum 'enumA'`);
276276
});
277-
});
277+
278+
describe('escapeSingleQuotes option', () => {
279+
test('deprecation reason changed with escaped single quotes', async () => {
280+
const a = buildSchema(/* GraphQL */ `
281+
type Query {
282+
fieldA: String
283+
}
284+
285+
enum enumA {
286+
A @deprecated(reason: "It's old")
287+
B
288+
}
289+
`);
290+
291+
const b = buildSchema(/* GraphQL */ `
292+
type Query {
293+
fieldA: String
294+
}
295+
296+
enum enumA {
297+
A @deprecated(reason: "It's new")
298+
B
299+
}
300+
`);
301+
302+
const changes = await diff(a, b, [], { escapeSingleQuotes: true });
303+
const change = findFirstChangeByPath(changes, 'enumA.A');
304+
305+
expect(changes.length).toEqual(1);
306+
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
307+
expect(change.message).toEqual(
308+
`Enum value 'enumA.A' deprecation reason changed from 'It\\'s old' to 'It\\'s new'`,
309+
);
310+
});
311+
312+
test('deprecation reason added with escaped single quotes', async () => {
313+
const a = buildSchema(/* GraphQL */ `
314+
type Query {
315+
fieldA: String
316+
}
317+
318+
enum enumA {
319+
A
320+
B
321+
}
322+
`);
323+
324+
const b = buildSchema(/* GraphQL */ `
325+
type Query {
326+
fieldA: String
327+
}
328+
329+
enum enumA {
330+
A @deprecated(reason: "Don't use this")
331+
B
332+
}
333+
`);
334+
335+
const changes = await diff(a, b, [], { escapeSingleQuotes: true });
336+
const change = findFirstChangeByPath(changes, 'enumA.A');
337+
338+
expect(changes.length).toEqual(2);
339+
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
340+
expect(change.message).toEqual(
341+
`Enum value 'enumA.A' was deprecated with reason 'Don\\'t use this'`,
342+
);
343+
});
344+
345+
test('deprecation reason without single quotes is unchanged', async () => {
346+
const a = buildSchema(/* GraphQL */ `
347+
type Query {
348+
fieldA: String
349+
}
350+
351+
enum enumA {
352+
A @deprecated(reason: "Old Reason")
353+
B
354+
}
355+
`);
356+
357+
const b = buildSchema(/* GraphQL */ `
358+
type Query {
359+
fieldA: String
360+
}
361+
362+
enum enumA {
363+
A @deprecated(reason: "New Reason")
364+
B
365+
}
366+
`);
367+
368+
const changes = await diff(a, b, [], { escapeSingleQuotes: true });
369+
const change = findFirstChangeByPath(changes, 'enumA.A');
370+
371+
expect(changes.length).toEqual(1);
372+
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
373+
expect(change.message).toEqual(
374+
`Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`,
375+
);
376+
});
377+
});
378+
});

packages/core/__tests__/utils/string.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { safeString } from '../../src/utils/string.js';
1+
import { fmt, safeString } from '../../src/utils/string.js';
22

33
test('scalars', () => {
44
expect(safeString(0)).toBe('0');
@@ -33,3 +33,32 @@ test('array', () => {
3333
'[ { foo: 42 } ]',
3434
);
3535
});
36+
37+
describe('fmt', () => {
38+
test('returns string unchanged by default', () => {
39+
expect(fmt('test')).toBe('test');
40+
expect(fmt('enumA.B')).toBe('enumA.B');
41+
});
42+
43+
test('returns string unchanged when config is undefined', () => {
44+
expect(fmt('test', undefined)).toBe('test');
45+
expect(fmt('enumA.B', undefined)).toBe('enumA.B');
46+
});
47+
48+
test('returns string unchanged when escapeSingleQuotes is false', () => {
49+
expect(fmt('test', { escapeSingleQuotes: false })).toBe('test');
50+
expect(fmt("It's a test", { escapeSingleQuotes: false })).toBe("It's a test");
51+
});
52+
53+
test('escapes single quotes when escapeSingleQuotes is true', () => {
54+
expect(fmt("It's a test", { escapeSingleQuotes: true })).toBe("It\\'s a test");
55+
expect(fmt("Don't do this", { escapeSingleQuotes: true })).toBe("Don\\'t do this");
56+
expect(fmt("'quoted'", { escapeSingleQuotes: true })).toBe("\\'quoted\\'");
57+
});
58+
59+
test('handles strings without single quotes when escapeSingleQuotes is true', () => {
60+
expect(fmt('test', { escapeSingleQuotes: true })).toBe('test');
61+
expect(fmt('Old Reason', { escapeSingleQuotes: true })).toBe('Old Reason');
62+
expect(fmt('', { escapeSingleQuotes: true })).toBe('');
63+
});
64+
});

packages/core/src/diff/changes/enum.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,11 @@ import {
1111
EnumValueDescriptionChangedChange,
1212
EnumValueRemovedChange,
1313
} from './change.js';
14+
import { ConsiderUsageConfig } from '../rules/consider-usage.js';
15+
import { fmt } from '../../utils/string.js';
1416

1517
function buildEnumValueRemovedMessage(args: EnumValueRemovedChange['meta']) {
16-
return `Enum value '${args.removedEnumValueName}' ${
17-
args.isEnumValueDeprecated ? '(deprecated) ' : ''
18-
}was removed from enum '${args.enumName}'`;
18+
return `Enum value '${args.removedEnumValueName}' ${args.isEnumValueDeprecated ? '(deprecated) ' : ''}was removed from enum '${args.enumName}'`;
1919
}
2020

2121
const enumValueRemovedCriticalityBreakingReason = `Removing an enum value will cause existing queries that use this enum value to error.`;
@@ -79,25 +79,24 @@ export function enumValueAdded(
7979
});
8080
}
8181

82-
function buildEnumValueDescriptionChangedMessage(args: EnumValueDescriptionChangedChange['meta']) {
82+
function buildEnumValueDescriptionChangedMessage(args: EnumValueDescriptionChangedChange['meta'], config?: ConsiderUsageConfig) {
83+
const oldDesc = fmt(args.oldEnumValueDescription ?? 'undefined', config);
84+
const newDesc = fmt(args.newEnumValueDescription ?? 'undefined', config);
8385
return args.oldEnumValueDescription === null
84-
? `Description '${args.newEnumValueDescription ?? 'undefined'}' was added to enum value '${
85-
args.enumName
86-
}.${args.enumValueName}'`
87-
: `Description for enum value '${args.enumName}.${args.enumValueName}' changed from '${
88-
args.oldEnumValueDescription ?? 'undefined'
89-
}' to '${args.newEnumValueDescription ?? 'undefined'}'`;
86+
? `Description '${newDesc}' was added to enum value '${args.enumName}.${args.enumValueName}'`
87+
: `Description for enum value '${args.enumName}.${args.enumValueName}' changed from '${oldDesc}' to '${newDesc}'`;
9088
}
9189

9290
export function enumValueDescriptionChangedFromMeta(
9391
args: EnumValueDescriptionChangedChange,
92+
config?: ConsiderUsageConfig
9493
): Change<typeof ChangeType.EnumValueDescriptionChanged> {
9594
return {
9695
criticality: {
9796
level: CriticalityLevel.NonBreaking,
9897
},
9998
type: ChangeType.EnumValueDescriptionChanged,
100-
message: buildEnumValueDescriptionChangedMessage(args.meta),
99+
message: buildEnumValueDescriptionChangedMessage(args.meta, config),
101100
path: [args.meta.enumName, args.meta.enumValueName].join('.'),
102101
meta: args.meta,
103102
} as const;
@@ -107,6 +106,7 @@ export function enumValueDescriptionChanged(
107106
newEnum: GraphQLEnumType,
108107
oldValue: GraphQLEnumValue,
109108
newValue: GraphQLEnumValue,
109+
config?: ConsiderUsageConfig
110110
): Change<typeof ChangeType.EnumValueDescriptionChanged> {
111111
return enumValueDescriptionChangedFromMeta({
112112
type: ChangeType.EnumValueDescriptionChanged,
@@ -116,24 +116,28 @@ export function enumValueDescriptionChanged(
116116
oldEnumValueDescription: oldValue.description ?? null,
117117
newEnumValueDescription: newValue.description ?? null,
118118
},
119-
});
119+
}, config);
120120
}
121121

122122
function buildEnumValueDeprecationChangedMessage(
123123
args: EnumValueDeprecationReasonChangedChange['meta'],
124+
config?: ConsiderUsageConfig
124125
) {
125-
return `Enum value '${args.enumName}.${args.enumValueName}' deprecation reason changed from '${args.oldEnumValueDeprecationReason}' to '${args.newEnumValueDeprecationReason}'`;
126+
const oldReason = fmt(args.oldEnumValueDeprecationReason, config);
127+
const newReason = fmt(args.newEnumValueDeprecationReason, config);
128+
return `Enum value '${args.enumName}.${args.enumValueName}' deprecation reason changed from '${oldReason}' to '${newReason}'`;
126129
}
127130

128131
export function enumValueDeprecationReasonChangedFromMeta(
129132
args: EnumValueDeprecationReasonChangedChange,
133+
config?: ConsiderUsageConfig
130134
) {
131135
return {
132136
criticality: {
133137
level: CriticalityLevel.NonBreaking,
134138
},
135139
type: ChangeType.EnumValueDeprecationReasonChanged,
136-
message: buildEnumValueDeprecationChangedMessage(args.meta),
140+
message: buildEnumValueDeprecationChangedMessage(args.meta, config),
137141
path: [args.meta.enumName, args.meta.enumValueName].join('.'),
138142
meta: args.meta,
139143
} as const;
@@ -143,6 +147,7 @@ export function enumValueDeprecationReasonChanged(
143147
newEnum: GraphQLEnumType,
144148
oldValue: GraphQLEnumValue,
145149
newValue: GraphQLEnumValue,
150+
config?: ConsiderUsageConfig,
146151
): Change<typeof ChangeType.EnumValueDeprecationReasonChanged> {
147152
return enumValueDeprecationReasonChangedFromMeta({
148153
type: ChangeType.EnumValueDeprecationReasonChanged,
@@ -152,24 +157,27 @@ export function enumValueDeprecationReasonChanged(
152157
oldEnumValueDeprecationReason: oldValue.deprecationReason ?? '',
153158
newEnumValueDeprecationReason: newValue.deprecationReason ?? '',
154159
},
155-
});
160+
}, config);
156161
}
157162

158163
function buildEnumValueDeprecationReasonAddedMessage(
159164
args: EnumValueDeprecationReasonAddedChange['meta'],
165+
config?: ConsiderUsageConfig,
160166
) {
161-
return `Enum value '${args.enumName}.${args.enumValueName}' was deprecated with reason '${args.addedValueDeprecationReason}'`;
167+
const reason = fmt(args.addedValueDeprecationReason, config);
168+
return `Enum value '${args.enumName}.${args.enumValueName}' was deprecated with reason '${reason}'`;
162169
}
163170

164171
export function enumValueDeprecationReasonAddedFromMeta(
165172
args: EnumValueDeprecationReasonAddedChange,
173+
config?: ConsiderUsageConfig,
166174
) {
167175
return {
168176
criticality: {
169177
level: CriticalityLevel.NonBreaking,
170178
},
171179
type: ChangeType.EnumValueDeprecationReasonAdded,
172-
message: buildEnumValueDeprecationReasonAddedMessage(args.meta),
180+
message: buildEnumValueDeprecationReasonAddedMessage(args.meta, config),
173181
path: [args.meta.enumName, args.meta.enumValueName].join('.'),
174182
meta: args.meta,
175183
} as const;
@@ -179,6 +187,7 @@ export function enumValueDeprecationReasonAdded(
179187
newEnum: GraphQLEnumType,
180188
oldValue: GraphQLEnumValue,
181189
newValue: GraphQLEnumValue,
190+
config?: ConsiderUsageConfig,
182191
): Change<typeof ChangeType.EnumValueDeprecationReasonAdded> {
183192
return enumValueDeprecationReasonAddedFromMeta({
184193
type: ChangeType.EnumValueDeprecationReasonAdded,
@@ -187,7 +196,7 @@ export function enumValueDeprecationReasonAdded(
187196
enumValueName: oldValue.name,
188197
addedValueDeprecationReason: newValue.deprecationReason ?? '',
189198
},
190-
});
199+
}, config);
191200
}
192201

193202
function buildEnumValueDeprecationReasonRemovedMessage(

packages/core/src/diff/enum.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ import {
99
enumValueDescriptionChanged,
1010
enumValueRemoved,
1111
} from './changes/enum.js';
12+
import { ConsiderUsageConfig } from './rules/consider-usage.js';
1213
import { AddChange } from './schema.js';
1314

1415
export function changesInEnum(
1516
oldEnum: GraphQLEnumType,
1617
newEnum: GraphQLEnumType,
1718
addChange: AddChange,
19+
config?: ConsiderUsageConfig,
1820
) {
1921
compareLists(oldEnum.getValues(), newEnum.getValues(), {
2022
onAdded(value) {
@@ -28,16 +30,16 @@ export function changesInEnum(
2830
const newValue = value.newVersion;
2931

3032
if (isNotEqual(oldValue.description, newValue.description)) {
31-
addChange(enumValueDescriptionChanged(newEnum, oldValue, newValue));
33+
addChange(enumValueDescriptionChanged(newEnum, oldValue, newValue, config));
3234
}
3335

3436
if (isNotEqual(oldValue.deprecationReason, newValue.deprecationReason)) {
3537
if (isVoid(oldValue.deprecationReason)) {
36-
addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue));
38+
addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue, config));
3739
} else if (isVoid(newValue.deprecationReason)) {
3840
addChange(enumValueDeprecationReasonRemoved(newEnum, oldValue, newValue));
3941
} else {
40-
addChange(enumValueDeprecationReasonChanged(newEnum, oldValue, newValue));
42+
addChange(enumValueDeprecationReasonChanged(newEnum, oldValue, newValue, config));
4143
}
4244
}
4345

packages/core/src/diff/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export function diff(
1616
rules: Rule[] = [],
1717
config?: rules.ConsiderUsageConfig,
1818
): Promise<Change[]> {
19-
const changes = diffSchema(oldSchema, newSchema);
19+
const changes = diffSchema(oldSchema, newSchema, config);
2020

2121
return rules.reduce(async (prev, rule) => {
2222
const prevChanges = await prev;

packages/core/src/diff/rules/consider-usage.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ export interface ConsiderUsageConfig {
3131
* false - BREAKING
3232
*/
3333
checkUsage?: UsageHandler;
34+
/**
35+
* Whether to escape single quotes originating from input schema
36+
*
37+
* When true, escapes single quotes (') from input schema strings by replacing them with \'.
38+
* When false or undefined, no escaping is performed.
39+
*
40+
* Note: Only affects deprecation reason messages.
41+
*
42+
* @default false
43+
*/
44+
escapeSingleQuotes?: boolean;
3445
}
3546

3647
export const considerUsage: Rule<ConsiderUsageConfig> = async ({ changes, config }) => {

0 commit comments

Comments
 (0)