Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/large-walls-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-inspector/core': minor
---

Add escapeSingleQuotes option for enum change messages
103 changes: 102 additions & 1 deletion packages/core/__tests__/diff/enum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,4 +274,105 @@ describe('enum', () => {
expect(change.criticality.reason).toBeDefined();
expect(change.message).toEqual(`Enum value 'C' was added to enum 'enumA'`);
});
});

describe('escapeSingleQuotes option', () => {
test('deprecation reason changed with escaped single quotes', async () => {
const a = buildSchema(/* GraphQL */ `
type Query {
fieldA: String
}

enum enumA {
A @deprecated(reason: "It's old")
B
}
`);

const b = buildSchema(/* GraphQL */ `
type Query {
fieldA: String
}

enum enumA {
A @deprecated(reason: "It's new")
B
}
`);

const changes = await diff(a, b, [], { escapeSingleQuotes: true });
const change = findFirstChangeByPath(changes, 'enumA.A');

expect(changes.length).toEqual(1);
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.message).toEqual(
`Enum value 'enumA.A' deprecation reason changed from 'It\\'s old' to 'It\\'s new'`,
);
});

test('deprecation reason added with escaped single quotes', async () => {
const a = buildSchema(/* GraphQL */ `
type Query {
fieldA: String
}

enum enumA {
A
B
}
`);

const b = buildSchema(/* GraphQL */ `
type Query {
fieldA: String
}

enum enumA {
A @deprecated(reason: "Don't use this")
B
}
`);

const changes = await diff(a, b, [], { escapeSingleQuotes: true });
const change = findFirstChangeByPath(changes, 'enumA.A');

expect(changes.length).toEqual(2);
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.message).toEqual(
`Enum value 'enumA.A' was deprecated with reason 'Don\\'t use this'`,
);
});

test('deprecation reason without single quotes is unchanged', async () => {
const a = buildSchema(/* GraphQL */ `
type Query {
fieldA: String
}

enum enumA {
A @deprecated(reason: "Old Reason")
B
}
`);

const b = buildSchema(/* GraphQL */ `
type Query {
fieldA: String
}

enum enumA {
A @deprecated(reason: "New Reason")
B
}
`);

const changes = await diff(a, b, [], { escapeSingleQuotes: true });
const change = findFirstChangeByPath(changes, 'enumA.A');

expect(changes.length).toEqual(1);
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.message).toEqual(
`Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`,
);
});
});
});
31 changes: 30 additions & 1 deletion packages/core/__tests__/utils/string.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { safeString } from '../../src/utils/string.js';
import { fmt, safeString } from '../../src/utils/string.js';

test('scalars', () => {
expect(safeString(0)).toBe('0');
Expand Down Expand Up @@ -33,3 +33,32 @@ test('array', () => {
'[ { foo: 42 } ]',
);
});

describe('fmt', () => {
test('returns string unchanged by default', () => {
expect(fmt('test')).toBe('test');
expect(fmt('enumA.B')).toBe('enumA.B');
});

test('returns string unchanged when config is undefined', () => {
expect(fmt('test', undefined)).toBe('test');
expect(fmt('enumA.B', undefined)).toBe('enumA.B');
});

test('returns string unchanged when escapeSingleQuotes is false', () => {
expect(fmt('test', { escapeSingleQuotes: false })).toBe('test');
expect(fmt("It's a test", { escapeSingleQuotes: false })).toBe("It's a test");
});

test('escapes single quotes when escapeSingleQuotes is true', () => {
expect(fmt("It's a test", { escapeSingleQuotes: true })).toBe("It\\'s a test");
expect(fmt("Don't do this", { escapeSingleQuotes: true })).toBe("Don\\'t do this");
expect(fmt("'quoted'", { escapeSingleQuotes: true })).toBe("\\'quoted\\'");
});

test('handles strings without single quotes when escapeSingleQuotes is true', () => {
expect(fmt('test', { escapeSingleQuotes: true })).toBe('test');
expect(fmt('Old Reason', { escapeSingleQuotes: true })).toBe('Old Reason');
expect(fmt('', { escapeSingleQuotes: true })).toBe('');
});
});
45 changes: 27 additions & 18 deletions packages/core/src/diff/changes/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import {
EnumValueDescriptionChangedChange,
EnumValueRemovedChange,
} from './change.js';
import { ConsiderUsageConfig } from '../rules/consider-usage.js';
import { fmt } from '../../utils/string.js';

function buildEnumValueRemovedMessage(args: EnumValueRemovedChange['meta']) {
return `Enum value '${args.removedEnumValueName}' ${
args.isEnumValueDeprecated ? '(deprecated) ' : ''
}was removed from enum '${args.enumName}'`;
return `Enum value '${args.removedEnumValueName}' ${args.isEnumValueDeprecated ? '(deprecated) ' : ''}was removed from enum '${args.enumName}'`;
}

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

function buildEnumValueDescriptionChangedMessage(args: EnumValueDescriptionChangedChange['meta']) {
function buildEnumValueDescriptionChangedMessage(args: EnumValueDescriptionChangedChange['meta'], config?: ConsiderUsageConfig) {
const oldDesc = fmt(args.oldEnumValueDescription ?? 'undefined', config);
const newDesc = fmt(args.newEnumValueDescription ?? 'undefined', config);
return args.oldEnumValueDescription === null
? `Description '${args.newEnumValueDescription ?? 'undefined'}' was added to enum value '${
args.enumName
}.${args.enumValueName}'`
: `Description for enum value '${args.enumName}.${args.enumValueName}' changed from '${
args.oldEnumValueDescription ?? 'undefined'
}' to '${args.newEnumValueDescription ?? 'undefined'}'`;
? `Description '${newDesc}' was added to enum value '${args.enumName}.${args.enumValueName}'`
: `Description for enum value '${args.enumName}.${args.enumValueName}' changed from '${oldDesc}' to '${newDesc}'`;
}

export function enumValueDescriptionChangedFromMeta(
args: EnumValueDescriptionChangedChange,
config?: ConsiderUsageConfig
): Change<typeof ChangeType.EnumValueDescriptionChanged> {
return {
criticality: {
level: CriticalityLevel.NonBreaking,
},
type: ChangeType.EnumValueDescriptionChanged,
message: buildEnumValueDescriptionChangedMessage(args.meta),
message: buildEnumValueDescriptionChangedMessage(args.meta, config),
path: [args.meta.enumName, args.meta.enumValueName].join('.'),
meta: args.meta,
} as const;
Expand All @@ -107,6 +106,7 @@ export function enumValueDescriptionChanged(
newEnum: GraphQLEnumType,
oldValue: GraphQLEnumValue,
newValue: GraphQLEnumValue,
config?: ConsiderUsageConfig
): Change<typeof ChangeType.EnumValueDescriptionChanged> {
return enumValueDescriptionChangedFromMeta({
type: ChangeType.EnumValueDescriptionChanged,
Expand All @@ -116,24 +116,28 @@ export function enumValueDescriptionChanged(
oldEnumValueDescription: oldValue.description ?? null,
newEnumValueDescription: newValue.description ?? null,
},
});
}, config);
}

function buildEnumValueDeprecationChangedMessage(
args: EnumValueDeprecationReasonChangedChange['meta'],
config?: ConsiderUsageConfig
) {
return `Enum value '${args.enumName}.${args.enumValueName}' deprecation reason changed from '${args.oldEnumValueDeprecationReason}' to '${args.newEnumValueDeprecationReason}'`;
const oldReason = fmt(args.oldEnumValueDeprecationReason, config);
const newReason = fmt(args.newEnumValueDeprecationReason, config);
return `Enum value '${args.enumName}.${args.enumValueName}' deprecation reason changed from '${oldReason}' to '${newReason}'`;
}

export function enumValueDeprecationReasonChangedFromMeta(
args: EnumValueDeprecationReasonChangedChange,
config?: ConsiderUsageConfig
) {
return {
criticality: {
level: CriticalityLevel.NonBreaking,
},
type: ChangeType.EnumValueDeprecationReasonChanged,
message: buildEnumValueDeprecationChangedMessage(args.meta),
message: buildEnumValueDeprecationChangedMessage(args.meta, config),
path: [args.meta.enumName, args.meta.enumValueName].join('.'),
meta: args.meta,
} as const;
Expand All @@ -143,6 +147,7 @@ export function enumValueDeprecationReasonChanged(
newEnum: GraphQLEnumType,
oldValue: GraphQLEnumValue,
newValue: GraphQLEnumValue,
config?: ConsiderUsageConfig,
): Change<typeof ChangeType.EnumValueDeprecationReasonChanged> {
return enumValueDeprecationReasonChangedFromMeta({
type: ChangeType.EnumValueDeprecationReasonChanged,
Expand All @@ -152,24 +157,27 @@ export function enumValueDeprecationReasonChanged(
oldEnumValueDeprecationReason: oldValue.deprecationReason ?? '',
newEnumValueDeprecationReason: newValue.deprecationReason ?? '',
},
});
}, config);
}

function buildEnumValueDeprecationReasonAddedMessage(
args: EnumValueDeprecationReasonAddedChange['meta'],
config?: ConsiderUsageConfig,
) {
return `Enum value '${args.enumName}.${args.enumValueName}' was deprecated with reason '${args.addedValueDeprecationReason}'`;
const reason = fmt(args.addedValueDeprecationReason, config);
return `Enum value '${args.enumName}.${args.enumValueName}' was deprecated with reason '${reason}'`;
}

export function enumValueDeprecationReasonAddedFromMeta(
args: EnumValueDeprecationReasonAddedChange,
config?: ConsiderUsageConfig,
) {
return {
criticality: {
level: CriticalityLevel.NonBreaking,
},
type: ChangeType.EnumValueDeprecationReasonAdded,
message: buildEnumValueDeprecationReasonAddedMessage(args.meta),
message: buildEnumValueDeprecationReasonAddedMessage(args.meta, config),
path: [args.meta.enumName, args.meta.enumValueName].join('.'),
meta: args.meta,
} as const;
Expand All @@ -179,6 +187,7 @@ export function enumValueDeprecationReasonAdded(
newEnum: GraphQLEnumType,
oldValue: GraphQLEnumValue,
newValue: GraphQLEnumValue,
config?: ConsiderUsageConfig,
): Change<typeof ChangeType.EnumValueDeprecationReasonAdded> {
return enumValueDeprecationReasonAddedFromMeta({
type: ChangeType.EnumValueDeprecationReasonAdded,
Expand All @@ -187,7 +196,7 @@ export function enumValueDeprecationReasonAdded(
enumValueName: oldValue.name,
addedValueDeprecationReason: newValue.deprecationReason ?? '',
},
});
}, config);
}

function buildEnumValueDeprecationReasonRemovedMessage(
Expand Down
8 changes: 5 additions & 3 deletions packages/core/src/diff/enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import {
enumValueDescriptionChanged,
enumValueRemoved,
} from './changes/enum.js';
import { ConsiderUsageConfig } from './rules/consider-usage.js';
import { AddChange } from './schema.js';

export function changesInEnum(
oldEnum: GraphQLEnumType,
newEnum: GraphQLEnumType,
addChange: AddChange,
config?: ConsiderUsageConfig,
) {
compareLists(oldEnum.getValues(), newEnum.getValues(), {
onAdded(value) {
Expand All @@ -28,16 +30,16 @@ export function changesInEnum(
const newValue = value.newVersion;

if (isNotEqual(oldValue.description, newValue.description)) {
addChange(enumValueDescriptionChanged(newEnum, oldValue, newValue));
addChange(enumValueDescriptionChanged(newEnum, oldValue, newValue, config));
}

if (isNotEqual(oldValue.deprecationReason, newValue.deprecationReason)) {
if (isVoid(oldValue.deprecationReason)) {
addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue));
addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue, config));
} else if (isVoid(newValue.deprecationReason)) {
addChange(enumValueDeprecationReasonRemoved(newEnum, oldValue, newValue));
} else {
addChange(enumValueDeprecationReasonChanged(newEnum, oldValue, newValue));
addChange(enumValueDeprecationReasonChanged(newEnum, oldValue, newValue, config));
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/diff/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function diff(
rules: Rule[] = [],
config?: rules.ConsiderUsageConfig,
): Promise<Change[]> {
const changes = diffSchema(oldSchema, newSchema);
const changes = diffSchema(oldSchema, newSchema, config);

return rules.reduce(async (prev, rule) => {
const prevChanges = await prev;
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/diff/rules/consider-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ export interface ConsiderUsageConfig {
* false - BREAKING
*/
checkUsage?: UsageHandler;
/**
* Whether to escape single quotes originating from input schema
*
* When true, escapes single quotes (') from input schema strings by replacing them with \'.
* When false or undefined, no escaping is performed.
*
* Note: Only affects deprecation reason messages.
*
* @default false
*/
escapeSingleQuotes?: boolean;
}

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