diff --git a/.cspell/cspell-dict.txt b/.cspell/cspell-dict.txt index 3de941c55..f3ed03849 100644 --- a/.cspell/cspell-dict.txt +++ b/.cspell/cspell-dict.txt @@ -79,6 +79,8 @@ fiels finalised follwoing fooz +fragmentified +fragmentify Fragmentization fufilled Gmsuh diff --git a/internals-js/src/__tests__/operations.test.ts b/internals-js/src/__tests__/operations.test.ts index 9657c4f46..d102a7f56 100644 --- a/internals-js/src/__tests__/operations.test.ts +++ b/internals-js/src/__tests__/operations.test.ts @@ -6,9 +6,9 @@ import { } from '../../dist/definitions'; import { buildSchema } from '../../dist/buildSchema'; import { FederationBlueprint } from '../../dist/federation'; -import { FragmentRestrictionAtType, MutableSelectionSet, NamedFragmentDefinition, Operation, operationFromDocument, parseOperation } from '../../dist/operations'; +import { fragmentify, FragmentRestrictionAtType, MutableSelectionSet, NamedFragmentDefinition, Operation, operationFromDocument, operationToDocument, parseOperation } from '../../dist/operations'; import './matchers'; -import { DocumentNode, FieldNode, GraphQLError, Kind, OperationDefinitionNode, OperationTypeNode, parse, SelectionNode, SelectionSetNode, validate } from 'graphql'; +import { DocumentNode, FieldNode, GraphQLError, Kind, OperationDefinitionNode, OperationTypeNode, parse, print, SelectionNode, SelectionSetNode, validate } from 'graphql'; import { assert } from '../utils'; import gql from 'graphql-tag'; @@ -2422,6 +2422,179 @@ describe('fragments optimization', () => { } `); }); + + describe('fragmentify', () => { + test('inline fragments to fragment definitions', () => { + const schema = parseSchema(` + type Query { + t: I + } + + interface I { + b: Int + u: U + } + + type T1 implements I { + a: Int + b: Int + u: U + } + + type T2 implements I { + x: String + y: String + b: Int + u: U + } + + union U = T1 | T2 + `); + const operation = parseOperation(schema, ` + { + t { + ... on T1 { + a + b + } + ... on T2 { + x + y + } + b + u { + ... on I { + b + } + ... on T1 { + a + b + } + ... on T2 { + x + y + } + } + } + } + `); + const fragmentifiedDocument = fragmentify(operationToDocument(operation), 1); + const fragmentified = print(fragmentifiedDocument); + expect(fragmentified).toMatchString(` + { + t { + ...T1FragmentV1 + ...T2FragmentV1 + b + u { + ...IFragmentV1 + ...T1FragmentV1 + ...T2FragmentV1 + } + } + } + + fragment T1FragmentV1 on T1 { + a + b + } + + fragment T2FragmentV1 on T2 { + x + y + } + + fragment IFragmentV1 on I { + b + } + `); + }); + + test(`wont create fragment definition for less than 2 selections`, () => { + const schema = parseSchema(` + type Query { + t: I + } + + interface I { + b: Int + u: U + } + + type T1 implements I { + a: Int + b: Int + u: U + } + + type T2 implements I { + x: String + y: String + b: Int + u: U + } + + union U = T1 | T2 + `); + const operation = parseOperation(schema, ` + { + t { + ... on T1 { + a + b + } + ... on T2 { + x + y + } + b + u { + ... on I { + b + } + ... on T1 { + a + b + } + ... on T2 { + x + y + } + } + } + } + `); + + const fragmentifiedDocument = fragmentify(operationToDocument(operation), 2); + const fragmentified = print(fragmentifiedDocument); + expect(fragmentified).toMatchString(` + { + t { + ...T1FragmentV1 + ...T2FragmentV1 + b + u { + ... on I { + b + } + ...T1FragmentV1 + ...T2FragmentV1 + } + } + } + + fragment T1FragmentV1 on T1 { + a + b + } + + fragment T2FragmentV1 on T2 { + x + y + } + `); + }); + }); }); describe('validations', () => { diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index c98573a06..add931566 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -15,7 +15,9 @@ import { SelectionSetNode, OperationTypeNode, NameNode, + visit, } from "graphql"; +import { ObjMap } from "graphql/jsutils/ObjMap"; import { baseType, Directive, @@ -3898,3 +3900,119 @@ export function operationToDocument(operation: Operation): DocumentNode { definitions: [operationAST as DefinitionNode].concat(fragmentASTs), }; } + +interface CountedFragmentDefinitionNode { + total: number; + fragments: ObjMap +} + +interface InlineFragmentToFragmentSpreadResult { + fragmentSpread: FragmentSpreadNode; + fragmentDefinition: FragmentDefinitionNode; + isNewFragmentDefinition: boolean; +} + +export function fragmentify( + document: DocumentNode, + minSelectionsForFragment: number = 2 +): DocumentNode { + const newFragmentDefinitions: ObjMap = {}; + + function getInlineFragmentSelections( + inlineFragment: InlineFragmentNode + ): string[] { + const selectionSetsToVisit: SelectionSetNode[] = [inlineFragment.selectionSet]; + const names: string[] = []; + let selectionSet: SelectionSetNode | undefined; + while((selectionSet = selectionSetsToVisit.pop())) { + for (const selection of selectionSet.selections) { + if ((selection.kind === Kind.FIELD || selection.kind === Kind.INLINE_FRAGMENT) && selection.selectionSet) { + selectionSetsToVisit.push(selection.selectionSet); + } else if (selection.kind === Kind.FIELD) { + names.push(selection.alias?.value ?? selection.name.value); + } else if (selection.kind === Kind.FRAGMENT_SPREAD) { + names.push(selection.name.value); + } + } + } + return names; + } + + function inlineFragmentToFragmentSpread( + inlineFragment: InlineFragmentNode + ): InlineFragmentToFragmentSpreadResult | undefined { + // we are only interested in inline fragments with TypeCondition + // example: ...on User { } + // Constrain #1 + // inline fragments without TypeCondition: ...friendFields + // are going to be skipped as we would need the schema to know which type this fragment is applied to. + if (!inlineFragment.typeCondition) return undefined; + // Constrain #2 + // we are not going to attempt to create fragment definitions for inline fragments with directives + // ... @include(if: $shouldInclude) + if (inlineFragment.directives && inlineFragment.directives.length > 0) return undefined; + // Constrain #3 + // we are not going to attempt to create as fragment definition for inline fragments with less than [minSelectionsForFragment] + const inlineFragmentSelections = getInlineFragmentSelections(inlineFragment); + if (minSelectionsForFragment > inlineFragmentSelections.length) return undefined; + + const inlineFragmentTypeName = inlineFragment.typeCondition.name.value; + const fragmentName = `${inlineFragmentTypeName}Fragment`; + const fragmentIdentifier = inlineFragmentSelections.join(','); + + if (!newFragmentDefinitions[fragmentName]) { + newFragmentDefinitions[fragmentName] = { + total: 0, + fragments: {} + }; + } + + let isNewFragmentDefinition = false; + if (!newFragmentDefinitions[fragmentName].fragments[fragmentIdentifier]) { + const fragmentNameWithVersion = `${fragmentName}V${++newFragmentDefinitions[fragmentName].total}`; + const fragmentDefinitionNode: FragmentDefinitionNode = { + kind: Kind.FRAGMENT_DEFINITION, + name: { kind: Kind.NAME, value: fragmentNameWithVersion }, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: inlineFragmentTypeName } + }, + selectionSet: inlineFragment.selectionSet, + }; + newFragmentDefinitions[fragmentName].fragments[fragmentIdentifier] = fragmentDefinitionNode; + isNewFragmentDefinition = true; + } + + return { + fragmentSpread: { + kind: Kind.FRAGMENT_SPREAD, + name: { + kind: Kind.NAME, + value: newFragmentDefinitions[fragmentName].fragments[fragmentIdentifier].name.value + } + }, + fragmentDefinition: newFragmentDefinitions[fragmentName].fragments[fragmentIdentifier], + isNewFragmentDefinition + } + } + + const newDocument = visit(document, { + SelectionSet: (selectionSet: SelectionSetNode): SelectionSetNode => { + for (let i = 0; i < selectionSet.selections.length; i++) { + if (selectionSet.selections[i].kind === Kind.INLINE_FRAGMENT) { + const inlineFragmentNode = selectionSet.selections[i] as InlineFragmentNode + const result = inlineFragmentToFragmentSpread(inlineFragmentNode); + if (result) { + (selectionSet.selections[i] as any) = result.fragmentSpread; + if (result.isNewFragmentDefinition) { + (document.definitions as any).push(result.fragmentDefinition); + } + } + } + } + return selectionSet; + } + }); + + return newDocument; +}