Skip to content

Commit e10de03

Browse files
authored
Ensure type extension fields are properly considered in unused schema detection (#6586)
1 parent 382a92e commit e10de03

File tree

7 files changed

+279
-44
lines changed

7 files changed

+279
-44
lines changed

.changeset/wet-panthers-notice.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'hive': patch
3+
---
4+
5+
Corrected an issue where fields from type extensions were not marked as unused when appropriate
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { addDays, formatISO } from 'date-fns';
2+
import { ProjectType } from 'testkit/gql/graphql';
3+
import { waitFor } from '../../../testkit/flow';
4+
// eslint-disable-next-line import/no-extraneous-dependencies
5+
import { graphql } from '../../../testkit/gql';
6+
import { execute } from '../../../testkit/graphql';
7+
import { initSeed } from '../../../testkit/seed';
8+
9+
const IntegrationTestsUnusedSchemaQuery = graphql(/* GraphQL */ `
10+
query IntegrationTestsUnusedSchema(
11+
$usageInput: UnusedSchemaExplorerUsageInput!
12+
$targetRef: TargetReferenceInput!
13+
) {
14+
latestValidVersion(target: $targetRef) {
15+
unusedSchema(usage: $usageInput) {
16+
types {
17+
__typename
18+
... on GraphQLObjectType {
19+
name
20+
fields {
21+
name
22+
usage {
23+
isUsed
24+
}
25+
}
26+
}
27+
}
28+
}
29+
}
30+
}
31+
`);
32+
33+
test.concurrent(
34+
'a field from a type extension should be a part of unused schema if unused',
35+
async ({ expect }) => {
36+
const { createOrg } = await initSeed().createOwner();
37+
const { createProject } = await createOrg();
38+
const { createTargetAccessToken, target } = await createProject(ProjectType.Single);
39+
40+
// Create a token with write rights
41+
const writeToken = await createTargetAccessToken({});
42+
43+
// Publish schema with write rights
44+
const publishResult = await writeToken
45+
.publishSchema({
46+
sdl: /* GraphQL */ `
47+
type Query {
48+
user: User
49+
}
50+
51+
type User {
52+
id: ID!
53+
}
54+
55+
extend type User {
56+
name: String
57+
}
58+
`,
59+
})
60+
.then(r => r.expectNoGraphQLErrors());
61+
// Schema publish should be successful
62+
expect(publishResult.schemaPublish.__typename).toBe('SchemaPublishSuccess');
63+
64+
const period = {
65+
from: formatISO(addDays(new Date(), -7)),
66+
to: formatISO(addDays(new Date(), 1)),
67+
};
68+
69+
const firstQuery = await execute({
70+
document: IntegrationTestsUnusedSchemaQuery,
71+
variables: {
72+
targetRef: {
73+
byId: target.id,
74+
},
75+
usageInput: {
76+
period,
77+
},
78+
},
79+
authToken: writeToken.secret,
80+
}).then(r => r.expectNoGraphQLErrors());
81+
82+
expect(firstQuery.latestValidVersion?.unusedSchema?.types).toHaveLength(2);
83+
84+
let userType = firstQuery.latestValidVersion?.unusedSchema?.types.find(t =>
85+
'name' in t ? t.name === 'User' : false,
86+
);
87+
88+
if (!userType) {
89+
throw new Error('User type not found');
90+
}
91+
92+
if (userType.__typename !== 'GraphQLObjectType') {
93+
throw new Error('User type is not an object type');
94+
}
95+
96+
let idField = userType.fields.find(f => f.name === 'id');
97+
let nameField = userType.fields.find(f => f.name === 'name');
98+
99+
expect(idField?.usage.isUsed).toEqual(false);
100+
expect(nameField, 'User.name should exist').toBeDefined();
101+
expect(nameField?.usage.isUsed, 'User.name should be unused').toEqual(false);
102+
103+
// mark name field as used
104+
const collectResult = await writeToken.collectUsage({
105+
size: 1,
106+
map: {
107+
op1: {
108+
operation: 'query UserName { user { name } }',
109+
operationName: 'UserName',
110+
fields: ['Query', 'Query.user', 'User', 'User.name'],
111+
},
112+
},
113+
operations: [
114+
{
115+
operationMapKey: 'op1',
116+
timestamp: Date.now(),
117+
execution: {
118+
ok: true,
119+
duration: 200_000_000,
120+
errorsTotal: 0,
121+
},
122+
},
123+
],
124+
});
125+
expect(collectResult.status).toEqual(200);
126+
await waitFor(8000);
127+
128+
const secondQuery = await execute({
129+
document: IntegrationTestsUnusedSchemaQuery,
130+
variables: {
131+
targetRef: {
132+
byId: target.id,
133+
},
134+
usageInput: {
135+
period,
136+
},
137+
},
138+
authToken: writeToken.secret,
139+
}).then(r => r.expectNoGraphQLErrors());
140+
141+
expect(secondQuery.latestValidVersion?.unusedSchema?.types).toHaveLength(1);
142+
143+
userType = secondQuery.latestValidVersion?.unusedSchema?.types.find(t =>
144+
'name' in t ? t.name === 'User' : false,
145+
);
146+
147+
if (!userType) {
148+
throw new Error('User type not found');
149+
}
150+
151+
if (userType.__typename !== 'GraphQLObjectType') {
152+
throw new Error('User type is not an object type');
153+
}
154+
155+
idField = userType.fields.find(f => f.name === 'id');
156+
nameField = userType.fields.find(f => f.name === 'name');
157+
158+
expect(idField?.usage.isUsed).toEqual(false);
159+
expect(nameField, 'User.name should not be used').toEqual(undefined);
160+
},
161+
);

packages/services/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@graphql-hive/core": "workspace:*",
1919
"@graphql-hive/federation-link-utils": "workspace:*",
2020
"@graphql-inspector/core": "5.1.0-alpha-20231208113249-34700c8a",
21+
"@graphql-tools/merge": "9.0.22",
2122
"@hive/cdn-script": "workspace:*",
2223
"@hive/emails": "workspace:*",
2324
"@hive/schema": "workspace:*",

packages/services/api/src/modules/schema/providers/schema-version-helper.ts

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { SchemaVersionMapper as SchemaVersion } from '../module.graphql.mappers';
2-
import { print } from 'graphql';
2+
import { isTypeSystemExtensionNode, print } from 'graphql';
33
import { Injectable, Scope } from 'graphql-modules';
44
import { CriticalityLevel } from '@graphql-inspector/core';
5+
import { mergeTypeDefs } from '@graphql-tools/merge';
56
import { traceFn } from '@hive/service-common';
67
import type { SchemaChangeType } from '@hive/storage';
78
import {
@@ -334,39 +335,68 @@ export class SchemaVersionHelper {
334335

335336
/**
336337
* There's a possibility that the composite schema SDL contains parts of the supergraph spec.
338+
*
339+
*
337340
* This is a problem because we want to show the public schema to the user, and the supergraph spec is not part of that.
338341
* This may happen when composite schema was produced with an old version of `transformSupergraphToPublicSchema`
339342
* or when supergraph sdl contained something new.
340343
*
341344
* This function will check if the SDL contains supergraph spec and if it does, it will transform it to public schema.
345+
*
346+
* ---
347+
*
348+
* There's also a possibility that the composite schema contains type extensions.
349+
* This is a problem, because other parts of the system may expect it to be clean from type extensions.
350+
*
351+
* This function will check for type system extensions and merge them into matching definitions.
342352
*/
343-
private autoFixCompositeSchemaSdl(sdl: string, versionId: string) {
353+
private autoFixCompositeSchemaSdl(sdl: string, versionId: string): string {
344354
const isFederationV1Output = sdl.includes('@core');
355+
// Poor's man check for type extensions to avoid parsing the SDL if it's not necessary.
356+
// Checks if the `extend` keyword is followed by a space or a newline and it's not a part of a word.
357+
const hasPotentiallyTypeExtensions = /\bextend(?=[\s\n])/.test(sdl);
358+
345359
/**
346360
* If the SDL is clean from Supergraph spec or it's an output of @apollo/federation, we don't need to transform it.
347361
* We ignore @apollo/federation, because we never really transformed the output of it to public schema.
348362
* Doing so might be a breaking change for some users (like: removed join__Graph type).
349363
*/
350-
351-
if (isFederationV1Output || !containsSupergraphSpec(sdl)) {
352-
return sdl;
364+
if (!isFederationV1Output && containsSupergraphSpec(sdl)) {
365+
this.logger.warn(
366+
'Composite schema SDL contains supergraph spec, transforming to public schema (versionId: %s)',
367+
versionId,
368+
);
369+
370+
const transformedSdl = print(
371+
transformSupergraphToPublicSchema(parseGraphQLSource(sdl, 'autoFixCompositeSchemaSdl')),
372+
);
373+
374+
this.logger.debug(
375+
transformedSdl === sdl
376+
? 'Transformation did not change the original SDL'
377+
: 'Transformation changed the original SDL',
378+
);
379+
380+
return transformedSdl;
353381
}
354382

355-
this.logger.warn(
356-
'Composite schema SDL contains supergraph spec, transforming to public schema (versionId: %s)',
357-
versionId,
358-
);
383+
/**
384+
* If the SDL has type extensions, we need to merge them into matching definitions.
385+
*/
386+
if (hasPotentiallyTypeExtensions) {
387+
const schemaAst = parseGraphQLSource(sdl, 'autoFixCompositeSchemaSdl');
388+
const hasTypeExtensions = schemaAst.definitions.some(isTypeSystemExtensionNode);
359389

360-
const transformedSdl = print(
361-
transformSupergraphToPublicSchema(parseGraphQLSource(sdl, 'autoFixCompositeSchemaSdl')),
362-
);
390+
if (!hasTypeExtensions) {
391+
return sdl;
392+
}
363393

364-
this.logger.debug(
365-
transformedSdl === sdl
366-
? 'Transformation did not change the original SDL'
367-
: 'Transformation changed the original SDL',
368-
);
394+
this.logger.warn(
395+
'Composite schema AST contains type extensions, merging them into matching definitions',
396+
);
397+
return print(mergeTypeDefs(schemaAst));
398+
}
369399

370-
return transformedSdl;
400+
return sdl;
371401
}
372402
}

packages/services/schema/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"@apollo/federation": "0.38.1",
1313
"@graphql-hive/external-composition": "workspace:*",
1414
"@graphql-hive/federation-link-utils": "workspace:*",
15+
"@graphql-tools/merge": "9.0.22",
1516
"@graphql-tools/stitch": "9.4.13",
1617
"@graphql-tools/stitching-directives": "3.1.24",
1718
"@hive/service-common": "workspace:*",

packages/services/schema/src/orchestrators.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildASTSchema,
66
concatAST,
77
GraphQLError,
8+
isTypeSystemExtensionNode,
89
Kind,
910
parse,
1011
print,
@@ -14,6 +15,7 @@ import {
1415
} from 'graphql';
1516
import { validateSDL } from 'graphql/validation/validate.js';
1617
import { extractLinkImplementations } from '@graphql-hive/federation-link-utils';
18+
import { mergeTypeDefs } from '@graphql-tools/merge';
1719
import { stitchSchemas } from '@graphql-tools/stitch';
1820
import { stitchingDirectives } from '@graphql-tools/stitching-directives';
1921
import type { ServiceLogger } from '@hive/service-common';
@@ -496,12 +498,18 @@ function createSingle(): Orchestrator {
496498
return {
497499
async composeAndValidate(schemas) {
498500
const schema = schemas[0];
499-
const schemaAst = parse(schema.raw);
501+
let schemaAst = parse(schema.raw);
502+
503+
// If the schema contains type system extension nodes, merge them into the schema.
504+
// We don't want to show many type extension of User, we want to show single User type.
505+
if (schemaAst.definitions.some(isTypeSystemExtensionNode)) {
506+
schemaAst = mergeTypeDefs(schemaAst);
507+
}
500508
const errors = validateSingleSDL(schemaAst);
501509

502510
return {
503511
errors,
504-
sdl: print(trimDescriptions(parse(schema.raw))),
512+
sdl: print(trimDescriptions(schemaAst)),
505513
supergraph: null,
506514
contracts: null,
507515
tags: null,

0 commit comments

Comments
 (0)