Skip to content

Advanced Breaking Change Detection #6764

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
4 changes: 2 additions & 2 deletions packages/libraries/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@
"prebuild": "tsc --build --clean",
"prepack": "node scripts/replace-workspace.mjs rimraf lib && tsc -b && oclif manifest && oclif readme",
"prepublishOnly": "oclif manifest && oclif readme",
"schema:check:federation": "pnpm start schema:check examples/federation.graphql --service reviews",
"schema:check:federation": "pnpm start schema:check examples/federation.reviews.graphql --service reviews",
"schema:check:single": "pnpm start schema:check examples/single.graphql",
"schema:check:stitching": "pnpm start schema:check --service posts examples/stitching.posts.graphql",
"schema:fetch:subgraphs": "pnpm start schema:fetch --type=subgraphs",
"schema:publish:federation": "pnpm start schema:publish --service reviews --url reviews.com/graphql examples/federation.reviews.graphql",
"schema:publish:federation": "pnpm start schema:publish --service reviews --url http://reviews.graphql-hive.dev/graphql examples/federation.reviews.graphql",
"start": "./bin/dev",
"version": "oclif readme && git add README.md"
},
Expand Down
49 changes: 47 additions & 2 deletions packages/libraries/core/src/client/collect-schema-coordinates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
NameNode,
ObjectFieldNode,
TypeInfo,
ValueNode,
visit,
visitWithTypeInfo,
} from 'graphql';
Expand All @@ -36,13 +37,37 @@ export function collectSchemaCoordinates(args: {
const entries = new Set<string>();
const collected_entire_named_types = new Set<string>();
const shouldAnalyzeVariableValues = args.processVariables === true && variables !== null;
const inputValuesProvided = new Map<string, number>();

function markAsUsed(id: string) {
if (!entries.has(id)) {
entries.add(id);
if (inputValuesProvided.has(id)) {
const valuesProvided = inputValuesProvided.get(id);
if (valuesProvided && valuesProvided > 0) {
inputValuesProvided.set(id, inputValuesProvided.get(id)! - 1);
entries.add(`${id}!`);
}
}
// always flag normal coordinate as used.
// This makes tracking usage easier later, because it's all tied to one coordinate,
// and it's only necessary to use the "!" form if specific conditions are met.
entries.add(id);
}

function countInputValueProvided(id: string) {
const existing = inputValuesProvided.get(id);
if (existing) {
inputValuesProvided.set(id, existing + 1);
} else {
inputValuesProvided.set(id, 1);
}
}

function valueNodeHasValue(v: ValueNode) {
return (
v.kind !== Kind.NULL && (v.kind !== Kind.VARIABLE || valueExists(variables?.[v.name.value]))
);
}

function makeId(...names: string[]): string {
return names.join('.');
}
Expand Down Expand Up @@ -129,6 +154,11 @@ export function collectSchemaCoordinates(args: {
}
}

function valueExists(v: unknown) {
return v !== null && v !== undefined;
}

// named type is the type that the variable represents
function collectVariable(namedType: GraphQLNamedInputType, variableValue: unknown) {
const variableValueArray = Array.isArray(variableValue) ? variableValue : [variableValue];
if (isInputObjectType(namedType)) {
Expand All @@ -137,6 +167,12 @@ export function collectSchemaCoordinates(args: {
// Collect only the used fields
for (const fieldName in variable) {
const field = namedType.getFields()[fieldName];

// check whether the value for this type is actually provided
if (valueExists(variable[fieldName])) {
countInputValueProvided(makeId(namedType.name, fieldName));
}

if (field) {
collectInputType(namedType.name, fieldName);
collectVariable(getNamedType(field.type), variable[fieldName]);
Expand Down Expand Up @@ -246,6 +282,11 @@ export function collectSchemaCoordinates(args: {
);
}

// check whether the value for this type is actually provided
if (valueNodeHasValue(node.value)) {
countInputValueProvided(makeId(parent.name, field.name, arg.name));
}

markAsUsed(makeId(parent.name, field.name, arg.name));
collectNode(node);
},
Expand Down Expand Up @@ -290,6 +331,10 @@ export function collectSchemaCoordinates(args: {
return null;
}

// is provided as a literal
if (valueNodeHasValue(node.value)) {
countInputValueProvided(makeId(parentInputTypeName, node.name.value));
}
collectNode(node);
collectInputType(parentInputTypeName, node.name.value);
},
Expand Down
243 changes: 238 additions & 5 deletions packages/libraries/core/tests/collect-schema-coordinates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ describe('collectSchemaCoordinates', () => {
variables: null,
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result)).toEqual(['Query.hello', 'Query.hello.message', 'String']);
expect(Array.from(result)).toEqual([
'Query.hello',
'Query.hello.message!',
'Query.hello.message',
'String',
]);
});

test('leaf field (enum)', () => {
Expand Down Expand Up @@ -157,7 +162,7 @@ describe('collectSchemaCoordinates', () => {
variables: null,
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result)).toEqual(['Query.node', 'Node.id', 'User.id']);
expect(Array.from(result).sort()).toEqual(['Query.node', 'Node.id', 'User.id'].sort());
});

test('custom scalar as argument', () => {
Expand All @@ -178,7 +183,9 @@ describe('collectSchemaCoordinates', () => {
variables: null,
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result)).toEqual(['Query.random', 'Query.random.json', 'JSON']);
expect(Array.from(result).sort()).toEqual(
['Query.random', 'Query.random.json', 'Query.random.json!', 'JSON'].sort(),
);
});

test('custom scalar in input object field', () => {
Expand All @@ -204,7 +211,16 @@ describe('collectSchemaCoordinates', () => {
variables: null,
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result)).toEqual(['Query.random', 'Query.random.input', 'I.json', 'JSON']);
expect(Array.from(result).sort()).toEqual(
[
'Query.random',
'Query.random.input',
'Query.random.input!',
'I.json',
'I.json!',
'JSON',
].sort(),
);
});

test('deeply nested inputs', () => {
Expand Down Expand Up @@ -237,7 +253,224 @@ describe('collectSchemaCoordinates', () => {
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result).sort()).toEqual(
['Query.random', 'Query.random.a', 'A.b', 'B.c', 'C.d', 'String'].sort(),
[
'Query.random',
'Query.random.a',
'Query.random.a!',
'A.b',
'A.b!',
'B.c',
'B.c!',
'C.d',
'C.d!',
'String',
].sort(),
);
});

test('required variable as argument', () => {
const schema = buildSchema(/* GraphQL */ `
type Query {
random(a: String): String
}
`);
const result = collectSchemaCoordinates({
documentNode: parse(/* GraphQL */ `
query Foo($a: String!) {
random(a: $a)
}
`),
schema,
processVariables: true,
variables: { a: 'B' },
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result).sort()).toEqual(
['Query.random', 'Query.random.a', 'Query.random.a!', 'String'].sort(),
);
});

test('required variable as input field', () => {
const schema = buildSchema(/* GraphQL */ `
type Query {
random(a: A): String
}

input A {
b: String
}
`);
const result = collectSchemaCoordinates({
documentNode: parse(/* GraphQL */ `
query Foo($b: String!) {
random(a: { b: $b })
}
`),
schema,
processVariables: true,
variables: { b: 'B' },
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result).sort()).toEqual(
['Query.random', 'Query.random.a', 'Query.random.a!', 'A.b', 'A.b!', 'String'].sort(),
);
});

test('undefined variable as input field', () => {
const schema = buildSchema(/* GraphQL */ `
type Query {
random(a: A): String
}

input A {
b: String
}
`);
const result = collectSchemaCoordinates({
documentNode: parse(/* GraphQL */ `
query Foo($b: String!) {
random(a: { b: $b })
}
`),
schema,
processVariables: true,
variables: null,
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result).sort()).toEqual(
['Query.random', 'Query.random.a', 'Query.random.a!', 'A.b', 'String'].sort(),
);
});

test('deeply nested variables (processVariables=true)', () => {
const schema = buildSchema(/* GraphQL */ `
type Query {
random(a: A): String
}

input A {
b: B
}

input B {
c: C
}

input C {
d: String
}
`);
const result = collectSchemaCoordinates({
documentNode: parse(/* GraphQL */ `
query Random($a: A) {
random(a: $a)
}
`),
schema,
processVariables: true,
variables: { a: { b: { c: { d: 'D' } } } },
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result).sort()).toEqual(
[
'Query.random',
'Query.random.a',
'Query.random.a!',
'A.b',
'A.b!',
'B.c',
'B.c!',
'C.d',
'C.d!',
'String',
].sort(),
);
});

test('deeply nested variables (processVariables=false)', () => {
const schema = buildSchema(/* GraphQL */ `
type Query {
random(a: A): String
}

input A {
b: B
}

input B {
c: C
}

input C {
d: String
}
`);
const result = collectSchemaCoordinates({
documentNode: parse(/* GraphQL */ `
query Random($a: A) {
random(a: $a)
}
`),
schema,
processVariables: false,
variables: { a: { b: { c: { d: 'D' } } } },
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result).sort()).toEqual(
['Query.random', 'Query.random.a', 'Query.random.a!', 'A.b', 'B.c', 'C.d', 'String'].sort(),
);
});

test('aliased field', () => {
const schema = buildSchema(/* GraphQL */ `
type Query {
random(a: String): String
}

input C {
d: String
}
`);
const result = collectSchemaCoordinates({
documentNode: parse(/* GraphQL */ `
query Random($a: String) {
foo: random(a: $a)
}
`),
schema,
processVariables: true,
variables: { a: 'B' },
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result).sort()).toEqual(
['Query.random', 'Query.random.a', 'Query.random.a!', 'String'].sort(),
);
});

test('multiple fields with mixed nullability', () => {
const schema = buildSchema(/* GraphQL */ `
type Query {
random(a: String): String
}

input C {
d: String
}
`);
const result = collectSchemaCoordinates({
documentNode: parse(/* GraphQL */ `
query Random($a: String) {
nullable: random(a: $a)
nonnullable: random(a: "B")
}
`),
schema,
processVariables: false,
variables: { a: null },
typeInfo: new TypeInfo(schema),
});
expect(Array.from(result).sort()).toEqual(
['Query.random', 'Query.random.a', 'Query.random.a!', 'String'].sort(),
);
});
});
Loading
Loading