Format service SDL and keep multiline comment spacing (#7664)

This commit is contained in:
jdolle 2026-02-12 16:28:05 -08:00 committed by GitHub
parent fb14a99087
commit ddea09bbc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 162 additions and 35 deletions

View file

@ -0,0 +1,5 @@
---
'@graphql-hive/cli': patch
---
Do not minify multiline descriptions in SDL on check/push

View file

@ -0,0 +1,5 @@
---
'hive': patch
---
Fix service SDL being printed on a single line in check and publish views

View file

@ -18,6 +18,7 @@ __snapshots__/
# test fixtures
integration-tests/fixtures/init-invalid-schema.graphql
integration-tests/fixtures/whitespace-oddity.graphql
/target
tmp

View file

@ -0,0 +1,13 @@
"""
Multi line comment:
1. Foo
2. Bar
3. Should stay in a list format
"""
type Query { status: Status }
# Another comment here
enum Status {
ACTIVE INACTIVE
PENDING
}

View file

@ -1207,6 +1207,8 @@ export function fetchLatestSchema(token: string) {
deletedService
}
}
sdl
supergraph
schemas {
nodes {
... on SingleSchema {

View file

@ -326,7 +326,7 @@ function runArtifactsCDNTests(
`artifact/${target.id}/services`,
);
expect(artifactContents.body).toMatchInlineSnapshot(
'[{"name":"ping","sdl":"type Query { ping: String }","url":"http://ping.com"}]',
`[{"name":"ping","sdl":"type Query {\\n ping: String\\n}","url":"http://ping.com"}]`,
);
const cdnAccessResult = await createCdnAccess();
@ -343,7 +343,7 @@ function runArtifactsCDNTests(
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toMatchInlineSnapshot(
'[{"name":"ping","sdl":"type Query { ping: String }","url":"http://ping.com"}]',
`[{"name":"ping","sdl":"type Query {\\n ping: String\\n}","url":"http://ping.com"}]`,
);
});
@ -375,7 +375,7 @@ function runArtifactsCDNTests(
`artifact/${target.id}/services`,
);
expect(artifactContents.body).toMatchInlineSnapshot(
'[{"name":"ping","sdl":"type Query { ping: String }","url":"http://ping.com"}]',
`[{"name":"ping","sdl":"type Query {\\n ping: String\\n}","url":"http://ping.com"}]`,
);
const cdnAccessResult = await createCdnAccess();
@ -720,7 +720,7 @@ function runArtifactsCDNTests(
expect(versionedResponse.headers.get('content-type')).toContain('application/json');
const servicesBody = await versionedResponse.text();
expect(servicesBody).toMatchInlineSnapshot(
'[{"name":"ping","sdl":"type Query { ping: String }","url":"http://ping.com"}]',
`[{"name":"ping","sdl":"type Query {\\n ping: String\\n}","url":"http://ping.com"}]`,
);
// Verify the versioned S3 key exists

View file

@ -606,14 +606,14 @@ test.concurrent('failed check due to policy error is persisted', async ({ expect
{
node: {
end: {
column: 17,
line: 2,
column: 11,
line: 1,
},
message: 'Description is required for type "Query"',
ruleId: 'require-description',
start: {
column: 12,
line: 2,
column: 6,
line: 1,
},
},
},

View file

@ -116,7 +116,10 @@ test.concurrent(
expect(firstNode).toEqual(
expect.objectContaining({
commit: '2',
source: expect.stringContaining('type Query { ping: String @auth pong: String }'),
source: `type Query {
ping: String @auth
pong: String
}`,
}),
);
expect(firstNode).not.toEqual(
@ -158,9 +161,14 @@ test.concurrent('directives should not be removed (federation)', async () => {
expect(latestResult.latestVersion?.schemas.nodes[0]).toEqual(
expect.objectContaining({
commit: 'abc123',
source: expect.stringContaining(
`type Query { me: User } type User @key(fields: "id") { id: ID! name: String }`,
),
source: `type Query {
me: User
}
type User @key(fields: "id") {
id: ID!
name: String
}`,
}),
);
});
@ -194,9 +202,14 @@ test.concurrent('directives should not be removed (stitching)', async () => {
expect(latestResult.latestVersion?.schemas.nodes[0]).toEqual(
expect.objectContaining({
commit: 'abc123',
source: expect.stringContaining(
`type Query { me: User } type User @key(selectionSet: "{ id }") { id: ID! name: String }`,
),
source: `type Query {
me: User
}
type User @key(selectionSet: "{ id }") {
id: ID!
name: String
}`,
}),
);
});
@ -229,9 +242,16 @@ test.concurrent('directives should not be removed (single)', async () => {
expect(latestResult.latestVersion?.schemas.nodes[0]).toEqual(
expect.objectContaining({
commit: 'abc123',
source: expect.stringContaining(
`directive @auth on FIELD_DEFINITION type Query { me: User @auth } type User { id: ID! name: String }`,
),
source: `directive @auth on FIELD_DEFINITION
type Query {
me: User @auth
}
type User {
id: ID!
name: String
}`,
}),
);
});

View file

@ -1102,3 +1102,74 @@ test.concurrent(
}
},
);
test.concurrent('schema:publish ignores SDL formatting', async ({ expect }) => {
const { createOrg } = await initSeed().createOwner();
const { inviteAndJoinMember, createProject, organization } = await createOrg();
await inviteAndJoinMember();
const { createTargetAccessToken, project, target } = await createProject(ProjectType.Federation);
const { secret, latestSchema } = await createTargetAccessToken({});
const targetSlug = [organization.slug, project.slug, target.slug].join('/');
await expect(
schemaPublish([
'--registry.accessToken',
secret,
'--author',
'Kamil',
'--target',
targetSlug,
'--service',
'whitespace',
'--url',
'https://example.graphql-hive.com/graphql',
'fixtures/whitespace-oddity.graphql',
]),
).resolves.toMatchInlineSnapshot(`
:::::::::::::::: CLI SUCCESS OUTPUT :::::::::::::::::
stdout--------------------------------------------:
Published initial schema.
Available at http://__URL__
`);
const latest = await latestSchema();
expect(latest.latestVersion?.schemas.nodes?.[0]?.source).toMatchInlineSnapshot(`
"""
Multi line comment:
1. Foo
2. Bar
3. Should stay in a list format
"""
type Query {
status: Status
}
enum Status {
ACTIVE
INACTIVE
PENDING
}
`);
// API Schema maintains formatting
expect(latest.latestVersion?.sdl).toEqual(
expect.stringContaining(`"""
Multi line comment:
1. Foo
2. Bar
3. Should stay in a list format
"""`),
);
// Supergraph maintains formatting
expect(latest.latestVersion?.supergraph).toEqual(
expect.stringContaining(`"""
Multi line comment:
1. Foo
2. Bar
3. Should stay in a list format
"""`),
);
});

View file

@ -167,6 +167,18 @@ export async function loadSchema(
return print(concatAST(sources.map(s => s.document!)));
}
export function minifySchema(schema: string): string {
return schema.replace(/\s+/g, ' ').trim();
export function minifySchema(schema: string) {
// Regex breakdown:
// 1. ("""[\s\S]*?""") -> Group 1: Triple-quoted blocks
// 2. #[^\r\n]* -> Matches comments starting with #
// 3. \s+ -> Matches one or more whitespaces
return schema
.replace(/(?:("""[\s\S]*?""")|#[^\r\n]*|\s+)/g, (match, group1) => {
// If it's a triple-quote block, return it exactly as is
if (group1) return group1;
// If it was a comment or whitespace(s), replace with a single space
return ' ';
})
.trim();
}

View file

@ -18,7 +18,6 @@ export type {
OrganizationBilling,
OrganizationInvitation,
} from './shared/entities';
export { minifySchema } from './shared/schema';
export { HiveError } from './shared/errors';
export { ProjectType } from './__generated__/types';
export type { AuthProviderType } from './__generated__/types';

View file

@ -614,9 +614,7 @@ export class SchemaPublisher {
existing: latestVersion
? toSingleSchemaInput(ensureSingleSchema(latestVersion.schemas))
: null,
incoming: {
sdl,
},
incoming: { sdl },
});
if ('result' in diffSchema) {
proposalChanges = diffSchema.result ?? null;
@ -627,9 +625,7 @@ export class SchemaPublisher {
}
checkResult = await this.models[ProjectType.SINGLE].check({
input: {
sdl: input.sdl,
},
input: { sdl },
selector,
latest: latestVersion
? {
@ -1291,6 +1287,7 @@ export class SchemaPublisher {
executor: () =>
this.internalPublish({
...input,
sdl: tryPrettifySDL(input.sdl),
checksum,
selector,
}),

View file

@ -80,10 +80,6 @@ export const parseGraphQLSource = traceInlineSync(
},
);
export function minifySchema(schema: string): string {
return schema.replace(/\s+/g, ' ').trim();
}
export function createConnection<TInput>(): {
nodes(nodes: readonly TInput[]): readonly TInput[];
total(nodes: readonly TInput[]): number;

View file

@ -87,6 +87,7 @@ const publishMutationDocument =
`;
async function publishSchema(args: { sdl: string; service?: string; target?: string }) {
const commit = `${Date.now()}`;
const response = await fetch(graphqlEndpoint, {
method: 'POST',
headers: {
@ -98,7 +99,7 @@ async function publishSchema(args: { sdl: string; service?: string; target?: str
variables: {
input: {
author: 'MoneyBoy',
commit: '1977',
commit,
sdl: args.sdl,
service: args.service,
url: `https://${args.service ? `${args.service}.` : ''}localhost/graphql`,
@ -110,6 +111,7 @@ async function publishSchema(args: { sdl: string; service?: string; target?: str
return response as {
data: {
schemaPublish: {
linkToWebsite: string;
valid: boolean;
} | null;
} | null;
@ -117,11 +119,15 @@ async function publishSchema(args: { sdl: string; service?: string; target?: str
};
}
function minifySchema(schema: string): string {
return schema.replace(/\s+/g, ' ').trim();
}
async function single() {
const schema = await loadSchema('scripts/seed-schemas/mono.graphql', {
loaders: [new GraphQLFileLoader()],
});
const sdl = printSchema(schema);
const sdl = minifySchema(printSchema(schema));
const result = await publishSchema({
sdl,
target,
@ -129,7 +135,7 @@ async function single() {
if (result?.errors || result?.data?.schemaPublish?.valid !== true) {
console.error(`Published schema is invalid.`);
} else {
console.log(`Published successfully.`);
console.log(`Published successfully (${result.data.schemaPublish?.linkToWebsite})`);
}
return result;
}
@ -147,7 +153,7 @@ async function federation() {
const service = d.location ? parsePath(d.location).name.replaceAll('.', '-') : undefined;
const result = await publishSchema({
sdl: d.rawSDL,
sdl: minifySchema(d.rawSDL),
service,
target,
});