From 720925588234339ba3126b25116455814fdb61b4 Mon Sep 17 00:00:00 2001 From: Philipp Melab Date: Wed, 23 Oct 2024 15:06:57 +0200 Subject: [PATCH] feat(operation-ids): choose between inline, attach and skip fragments --- .../codegen-operation-ids/src/index.test.ts | 140 +++++++++++++++++- .../codegen-operation-ids/src/index.ts | 27 +++- .../codegen-operation-ids/src/scan.test.ts | 98 ++++++++++++ .../codegen-operation-ids/src/scan.ts | 45 ++++++ 4 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 packages/npm/@amazeelabs/codegen-operation-ids/src/scan.test.ts create mode 100644 packages/npm/@amazeelabs/codegen-operation-ids/src/scan.ts diff --git a/packages/npm/@amazeelabs/codegen-operation-ids/src/index.test.ts b/packages/npm/@amazeelabs/codegen-operation-ids/src/index.test.ts index bc66ee4bb..512b581a4 100644 --- a/packages/npm/@amazeelabs/codegen-operation-ids/src/index.test.ts +++ b/packages/npm/@amazeelabs/codegen-operation-ids/src/index.test.ts @@ -3,6 +3,7 @@ import { buildSchema, parse } from 'graphql'; import { describe, expect, it } from 'vitest'; import { plugin } from './'; +import { inlineFragments } from './inline'; const schema = buildSchema(` type Query { @@ -19,8 +20,16 @@ const schema = buildSchema(` `); describe('mode: map', () => { - function runPlugin(documents: Array) { - return plugin(schema, documents, {}, { outputFile: 'map.json' }); + function runPlugin( + documents: Array, + mode: 'inline' | 'attach' | undefined = 'inline', + ) { + return plugin( + schema, + documents, + { fragments: mode }, + { outputFile: 'map.json' }, + ); } it('creates a map entry for a single operation', async () => { @@ -123,7 +132,30 @@ describe('mode: map', () => { `); }); - it('adds used fragments', async () => { + it('adds used fragments inline', async () => { + const result = await runPlugin([ + { + location: 'a.gql', + document: parse(` + fragment Page on Page { title } + query Home { loadPage(path: "/") { ...Page } } + `), + }, + ]); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:dc086da112964a8f85ae0520dab3fa68a84e067ee6aa8b0e06305f4cb5e9898a": "query Home { + loadPage(path: "/") { + ... on Page { + title + } + } + }", + } + `); + }); + + it('attaches used fragments', async () => { const result = await runPlugin([ { location: 'a.gql', @@ -135,7 +167,7 @@ describe('mode: map', () => { ]); expect(JSON.parse(result)).toMatchInlineSnapshot(` { - "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d": "query Home { + "HomeQuery:dc086da112964a8f85ae0520dab3fa68a84e067ee6aa8b0e06305f4cb5e9898a": "query Home { loadPage(path: "/") { ... on Page { title @@ -145,6 +177,7 @@ describe('mode: map', () => { } `); }); + it('inlines multiple invocations', async () => { const result = await runPlugin([ { @@ -166,7 +199,7 @@ describe('mode: map', () => { ]); expect(JSON.parse(result)).toMatchInlineSnapshot(` { - "HomeQuery:e8b5953fe0f339244ebb14102eddc5d0e23259606de6f697574f69bfe468ac53": "query Home { + "HomeQuery:9e615243c0c61d86531091aa395544df99db822d94d50a761c2809d61caf3164": "query Home { loadPage(path: "/") { ... on Page { title @@ -190,6 +223,59 @@ describe('mode: map', () => { } `); }); + + it('attaches multiple invocations', async () => { + const result = await runPlugin( + [ + { + location: 'a.gql', + document: parse(` + fragment Page on Page { title, related { path } } + fragment Teaser on Page { path } + query Home { + loadPage(path: "/") { + ...Page, + related { + ...Page + ...Teaser + } + } + } + `), + }, + ], + 'attach', + ); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:c25b93055bb9dce4d474a8e9031df3842a686ad4ad1b3ce2806ef528eb5b1c47": "query Home { + loadPage(path: "/") { + ...Page + related { + ...Page + ...Teaser + } + } + } + fragment Page on Page { + title + related { + path + } + } + fragment Page on Page { + title + related { + path + } + } + fragment Teaser on Page { + path + }", + } + `); + }); + it('adds nested fragments', async () => { const result = await runPlugin([ { @@ -207,7 +293,7 @@ describe('mode: map', () => { ]); expect(JSON.parse(result)).toMatchInlineSnapshot(` { - "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d": "query Home { + "HomeQuery:7f81601e1df0c179be7354f3f834201b9615d0ea191d4ba5f83bb45b0bfe052d": "query Home { loadPage(path: "/") { ... on Page { title @@ -223,6 +309,44 @@ describe('mode: map', () => { `); }); + it('attaches nested fragments', async () => { + const result = await runPlugin( + [ + { + location: 'a.gql', + document: parse(` + fragment RelatedPage on Page { title } + fragment Page on Page { title, related { ...RelatedPage } } + query Home { + loadPage(path: "/") { + ...Page, + } + } + `), + }, + ], + 'attach', + ); + expect(JSON.parse(result)).toMatchInlineSnapshot(` + { + "HomeQuery:fe7f24087d5deae03464a1e58e1c5a50cd6dbbbf4b4e68c41e5f6b9f2f947d3c": "query Home { + loadPage(path: "/") { + ...Page + } + } + fragment Page on Page { + title + related { + ...RelatedPage + } + } + fragment RelatedPage on Page { + title + }", + } + `); + }); + it('adds fragments from different documents', async () => { const result = await runPlugin([ { @@ -244,7 +368,7 @@ describe('mode: map', () => { ]); expect(JSON.parse(result)).toMatchInlineSnapshot(` { - "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d": "query Home { + "HomeQuery:dc086da112964a8f85ae0520dab3fa68a84e067ee6aa8b0e06305f4cb5e9898a": "query Home { loadPage(path: "/") { ... on Page { title @@ -282,7 +406,7 @@ describe('mode: map', () => { ]); expect(JSON.parse(result)).toMatchInlineSnapshot(` { - "HomeQuery:37d40553a898c4026ba372c8f42af3df9c3451953b65695b823a8e1e7b5fd90d": "query Home { + "HomeQuery:7f81601e1df0c179be7354f3f834201b9615d0ea191d4ba5f83bb45b0bfe052d": "query Home { loadPage(path: "/") { ... on Page { title diff --git a/packages/npm/@amazeelabs/codegen-operation-ids/src/index.ts b/packages/npm/@amazeelabs/codegen-operation-ids/src/index.ts index 4e1db38e8..4f267b059 100644 --- a/packages/npm/@amazeelabs/codegen-operation-ids/src/index.ts +++ b/packages/npm/@amazeelabs/codegen-operation-ids/src/index.ts @@ -11,6 +11,7 @@ import { } from 'graphql'; import { inlineFragments } from './inline'; +import { scanFragments } from './scan'; class OperationIdVisitor extends ClientSideBaseVisitor { _extractFragments() { @@ -30,16 +31,17 @@ class OperationIdVisitor extends ClientSideBaseVisitor { return `export const ${operationResultType} = "${queryId( node, + print(node), )}" as OperationId<${operationResultType},${operationVariablesTypes}${ hasRequiredVariables ? '' : ' | undefined' }>;`; } } -function queryId(node: OperationDefinitionNode) { +function queryId(node: OperationDefinitionNode, content: string) { return `${node.name?.value ?? 'anonymous'}${pascalCase( node.operation, - )}:${crypto.createHash('sha256').update(print(node)).digest('hex')}`; + )}:${crypto.createHash('sha256').update(content).digest('hex')}`; } export const plugin: PluginFunction = async ( @@ -69,9 +71,24 @@ export const plugin: PluginFunction = async ( const idMap = new Map(); visit(allAst, { OperationDefinition(node) { - const query = [print(inlineFragments(node, fragmentMap))]; - const id = queryId(node); - operationMap.set(id, query.join('\n')); + const query = [ + print( + config.fragments === 'inline' + ? inlineFragments(node, fragmentMap) + : node, + ), + ]; + if (config.fragments === 'attach') { + scanFragments(node, fragmentMap).forEach((name) => { + const fragment = fragmentMap.get(name); + if (fragment) { + query.push(print(fragment)); + } + }); + } + const queryString = query.join('\n'); + const id = queryId(node, queryString); + operationMap.set(id, queryString); if (node.name) { idMap.set(node.name.value, id); } diff --git a/packages/npm/@amazeelabs/codegen-operation-ids/src/scan.test.ts b/packages/npm/@amazeelabs/codegen-operation-ids/src/scan.test.ts new file mode 100644 index 000000000..c4872fd1d --- /dev/null +++ b/packages/npm/@amazeelabs/codegen-operation-ids/src/scan.test.ts @@ -0,0 +1,98 @@ +import { + DefinitionNode, + FragmentDefinitionNode, + Kind, + OperationDefinitionNode, + parse, + print, +} from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { scanFragments } from './scan'; + +function isFragmentDefinitionNode( + def: DefinitionNode, +): def is FragmentDefinitionNode { + return def.kind === Kind.FRAGMENT_DEFINITION; +} + +function isOperationDefinitionNode( + def: DefinitionNode, +): def is OperationDefinitionNode { + return def.kind === Kind.OPERATION_DEFINITION; +} + +describe('scanFragments', () => { + it('detects a fragment on a query', () => { + const doc = parse(` + query { + ...A + } + fragment A on Query { + myprop + } + `); + const [query] = doc.definitions.filter(isOperationDefinitionNode); + const [A] = doc.definitions.filter(isFragmentDefinitionNode); + const fragments = scanFragments(query, new Map(Object.entries({ A }))); + expect(fragments).toEqual(['A']); + }); + + it('detects a fragment on a field', () => { + const doc = parse(` + query { + a { + ...A + } + } + fragment A on A { + myprop + } + `); + const [query] = doc.definitions.filter(isOperationDefinitionNode); + const [A] = doc.definitions.filter(isFragmentDefinitionNode); + const fragments = scanFragments(query, new Map(Object.entries({ A }))); + expect(fragments).toEqual(['A']); + }); + + it('detects nested fragments', () => { + const doc = parse(`query { + a { + propA + ...A + } +} +fragment A on A { + propA + propB { + ...B + } +} +fragment B on B { + propC +}`); + const [query] = doc.definitions.filter(isOperationDefinitionNode); + const [A, B] = doc.definitions.filter(isFragmentDefinitionNode); + const fragments = scanFragments(query, new Map(Object.entries({ A, B }))); + expect(fragments).toEqual(['A', 'B']); + }); + it('detects mixed fragments', () => { + const doc = parse(`query { + a { + propA + ... on A { + propB { + ...B + } + } + } +} +fragment B on B { + propC +}`); + const [query] = doc.definitions.filter(isOperationDefinitionNode); + const [B] = doc.definitions.filter(isFragmentDefinitionNode); + const fragments = scanFragments(query, new Map(Object.entries({ B }))); + expect(fragments).toEqual(['B']); + }); +}); diff --git a/packages/npm/@amazeelabs/codegen-operation-ids/src/scan.ts b/packages/npm/@amazeelabs/codegen-operation-ids/src/scan.ts new file mode 100644 index 000000000..e679b0ee2 --- /dev/null +++ b/packages/npm/@amazeelabs/codegen-operation-ids/src/scan.ts @@ -0,0 +1,45 @@ +import { + ExecutableDefinitionNode, + FieldNode, + FragmentDefinitionNode, + FragmentSpreadNode, + InlineFragmentNode, + Kind, +} from 'graphql'; + +export function scanFragments< + TNode extends + | ExecutableDefinitionNode + | FieldNode + | InlineFragmentNode + | FragmentSpreadNode, +>(node: TNode, fragments: Map): Array { + const result: Array = []; + if (node.kind === Kind.FRAGMENT_SPREAD) { + return [node.name.value]; + } + node.selectionSet?.selections.forEach((sel) => { + if (sel.kind === Kind.FRAGMENT_SPREAD) { + result.push(sel.name.value); + const fragment = fragments.get(sel.name.value); + if (fragment) { + fragment.selectionSet.selections.forEach((sel) => { + scanFragments(sel, fragments).forEach((frag) => { + result.push(frag); + }); + }); + } + } else if (sel.kind === Kind.INLINE_FRAGMENT) { + sel.selectionSet.selections.forEach((sel) => { + scanFragments(sel, fragments).forEach((frag) => { + result.push(frag); + }); + }); + } else if (sel.kind === Kind.FIELD && sel.selectionSet) { + scanFragments(sel, fragments).forEach((frag) => { + result.push(frag); + }); + } + }); + return result; +}