diff --git a/exchanges/populate/src/helpers/node.ts b/exchanges/populate/src/helpers/node.ts index eea5b07ab7..133c4533fe 100644 --- a/exchanges/populate/src/helpers/node.ts +++ b/exchanges/populate/src/helpers/node.ts @@ -1,25 +1,16 @@ import { NameNode, - SelectionNode, - SelectionSetNode, GraphQLOutputType, isWrappingType, GraphQLWrappingType, Kind, } from 'graphql'; -export type SelectionSet = ReadonlyArray; export type GraphQLFlatType = Exclude; /** Returns the name of a given node */ export const getName = (node: { name: NameNode }): string => node.name.value; -/** Returns the SelectionSet for a given inline or defined fragment node */ -export const getSelectionSet = (node: { - selectionSet?: SelectionSetNode; -}): SelectionSet => - node.selectionSet !== undefined ? node.selectionSet.selections : []; - export const unwrapType = ( type: null | undefined | GraphQLOutputType ): GraphQLFlatType | null => { diff --git a/exchanges/populate/src/populateExchange.test.ts b/exchanges/populate/src/populateExchange.test.ts index 009ac06553..a0b87673c5 100644 --- a/exchanges/populate/src/populateExchange.test.ts +++ b/exchanges/populate/src/populateExchange.test.ts @@ -35,6 +35,7 @@ const schemaDef = ` type Todo implements Node { id: ID! text: String! + createdAt(timezone: String): String! creator: User! } @@ -201,22 +202,70 @@ describe('on query -> mutation', () => { expect(print(response[1].query)).toMatchInlineSnapshot(` "mutation MyMutation { addTodo { - ...Todo_PopulateFragment_0 - ...Todo_PopulateFragment_1 + __typename + id + text + creator { + __typename + id + name + } } - } + }" + `); + }); + }); +}); - fragment Todo_PopulateFragment_0 on Todo { - id - text - creator { +describe('on query -> mutation', () => { + const queryOp = makeOperation( + 'query', + { + key: 1234, + variables: undefined, + query: gql` + query { + todos { id - name + text + createdAt(timezone: "GMT+1") } } + `, + }, + context + ); - fragment Todo_PopulateFragment_1 on Todo { - text + const mutationOp = makeOperation( + 'mutation', + { + key: 5678, + variables: undefined, + query: gql` + mutation MyMutation { + addTodo @populate + } + `, + }, + context + ); + + describe('mutation query', () => { + it('matches snapshot', async () => { + const response = pipe( + fromArray([queryOp, mutationOp]), + populateExchange({ schema })(exchangeArgs), + toArray + ); + + expect(print(response[1].query)).toMatchInlineSnapshot(` + "mutation MyMutation { + addTodo { + __typename + id + text + createdAt(timezone: \\"GMT+1\\") + } }" `); }); @@ -285,45 +334,24 @@ describe('on (query w/ fragment) -> mutation', () => { expect(print(response[1].query)).toMatchInlineSnapshot(` "mutation MyMutation { addTodo { - ...Todo_PopulateFragment_0 ...TodoFragment + __typename + id + text + creator { + __typename + id + name + } } } fragment TodoFragment on Todo { id text - } - - fragment Todo_PopulateFragment_0 on Todo { - ...TodoFragment - creator { - ...CreatorFragment - } - } - - fragment CreatorFragment on User { - id - name }" `); }); - - it('includes user fragment', () => { - const response = pipe( - fromArray([queryOp, mutationOp]), - populateExchange({ schema })(exchangeArgs), - toArray - ); - - const fragments = getNodesByType( - response[1].query, - Kind.FRAGMENT_DEFINITION - ); - expect( - fragments.filter(f => 'name' in f && f.name.value === 'TodoFragment') - ).toHaveLength(1); - }); }); }); @@ -378,13 +406,10 @@ describe('on (query w/ unused fragment) -> mutation', () => { expect(print(response[1].query)).toMatchInlineSnapshot(` "mutation MyMutation { addTodo { - ...Todo_PopulateFragment_0 + __typename + id + text } - } - - fragment Todo_PopulateFragment_0 on Todo { - id - text }" `); }); @@ -454,19 +479,15 @@ describe('on query -> (mutation w/ interface return type)', () => { expect(print(response[1].query)).toMatchInlineSnapshot(` "mutation MyMutation { removeTodo { - ...User_PopulateFragment_0 - ...Todo_PopulateFragment_0 + ... on User { + __typename + id + } + ... on Todo { + __typename + id + } } - } - - fragment User_PopulateFragment_0 on User { - id - text - } - - fragment Todo_PopulateFragment_0 on Todo { - id - name }" `); }); @@ -483,11 +504,11 @@ describe('on query -> (mutation w/ union return type)', () => { query { todos { id - name + text } users { id - text + name } } `, @@ -520,26 +541,27 @@ describe('on query -> (mutation w/ union return type)', () => { expect(print(response[1].query)).toMatchInlineSnapshot(` "mutation MyMutation { updateTodo { - ...User_PopulateFragment_0 - ...Todo_PopulateFragment_0 + ... on User { + __typename + id + name + } + ... on Todo { + __typename + id + text + } } - } - - fragment User_PopulateFragment_0 on User { - id - text - } - - fragment Todo_PopulateFragment_0 on Todo { - id - name }" `); }); }); }); -describe('on query -> teardown -> mutation', () => { +// TODO: figure out how to behave with teardown, just removing and +// not requesting fields feels kinda incorrect as we would start having +// stale cache values here +describe.skip('on query -> teardown -> mutation', () => { const queryOp = makeOperation( 'query', { @@ -647,20 +669,18 @@ describe('interface returned in mutation', () => { expect(print(response[1].query)).toMatchInlineSnapshot(` "mutation MyMutation { addProduct { - ...SimpleProduct_PopulateFragment_0 - ...ComplexProduct_PopulateFragment_0 + ... on SimpleProduct { + __typename + id + price + } + ... on ComplexProduct { + __typename + id + price + tax + } } - } - - fragment SimpleProduct_PopulateFragment_0 on SimpleProduct { - id - price - } - - fragment ComplexProduct_PopulateFragment_0 on ComplexProduct { - id - price - tax }" `); }); @@ -716,31 +736,92 @@ describe('nested interfaces', () => { expect(print(response[1].query)).toMatchInlineSnapshot(` "mutation MyMutation { addProduct { - ...SimpleProduct_PopulateFragment_0 - ...ComplexProduct_PopulateFragment_0 + ... on SimpleProduct { + __typename + id + price + store { + __typename + id + name + address + } + } + ... on ComplexProduct { + __typename + id + price + tax + store { + __typename + id + name + website + } + } } - } + }" + `); + }); +}); - fragment SimpleProduct_PopulateFragment_0 on SimpleProduct { +describe('nested fragment', () => { + const fragment = gql` + fragment TodoFragment on Todo { + id + author { id - price - store { - id - name - address - } } + } + `; - fragment ComplexProduct_PopulateFragment_0 on ComplexProduct { - id - price - tax - store { + const queryOp = makeOperation( + 'query', + { + key: 1234, + variables: undefined, + query: gql` + query { + todos { + ...TodoFragment + } + } + ${fragment} + `, + }, + context + ); + + const mutationOp = makeOperation( + 'mutation', + { + key: 5678, + variables: undefined, + query: gql` + mutation MyMutation { + updateTodo @populate + } + `, + }, + context + ); + + it('should work with nested fragments', () => { + const response = pipe( + fromArray([queryOp, mutationOp]), + populateExchange({ schema })(exchangeArgs), + toArray + ); + + expect(print(response[1].query)).toMatchInlineSnapshot(` + "mutation MyMutation { + updateTodo { + ... on Todo { + __typename id - name - website } - }" - `); + } + }" + `); }); }); diff --git a/exchanges/populate/src/populateExchange.ts b/exchanges/populate/src/populateExchange.ts index f9a0f34476..080311bac7 100644 --- a/exchanges/populate/src/populateExchange.ts +++ b/exchanges/populate/src/populateExchange.ts @@ -1,33 +1,24 @@ import { - ASTNode, - DocumentNode, buildClientSchema, FragmentDefinitionNode, - GraphQLSchema, IntrospectionQuery, - FragmentSpreadNode, - isCompositeType, isAbstractType, Kind, - SelectionSetNode, GraphQLObjectType, SelectionNode, + GraphQLInterfaceType, + valueFromASTUntyped, + GraphQLScalarType, + FieldNode, + InlineFragmentNode, + FragmentSpreadNode, + ArgumentNode, } from 'graphql'; import { pipe, tap, map } from 'wonka'; -import { makeOperation, Exchange, Operation } from '@urql/core'; +import { Exchange, Operation, stringifyVariables } from '@urql/core'; -import { warn } from './helpers/help'; -import { - getName, - getSelectionSet, - unwrapType, - createNameNode, -} from './helpers/node'; -import { - traverse, - resolveFields, - getUsedFragmentNames, -} from './helpers/traverse'; +import { getName, GraphQLFlatType, unwrapType } from './helpers/node'; +import { traverse } from './helpers/traverse'; interface PopulateExchangeOpts { schema: IntrospectionQuery; @@ -35,6 +26,20 @@ interface PopulateExchangeOpts { const makeDict = (): any => Object.create(null); +/** stores information per each type it finds */ +type TypeKey = GraphQLObjectType | GraphQLInterfaceType; +/** stores all known fields per each type key */ +type FieldValue = Record; +type TypeFields = Map; +/** Describes information about a given field, i.e. type (owner), arguments, how many operations use this field */ +interface FieldUsage { + type: TypeKey; + args: null | { [key: string]: { value: any; kind: any } }; + fieldName: string; +} + +type FragmentMap = Record; + /** An exchange for auto-populating mutations with a required response body. */ export const populateExchange = ({ schema: ogSchema, @@ -45,9 +50,11 @@ export const populateExchange = ({ /** List of operation keys that have not been torn down. */ const activeOperations = new Set(); /** Collection of fragments used by the user. */ - const userFragments: UserFragmentMap = makeDict(); - /** Collection of actively in use type fragments. */ - const activeTypeFragments: TypeFragmentMap = makeDict(); + const userFragments: FragmentMap = makeDict(); + + // State of the global types & their fields + const typeFields: TypeFields = new Map(); + let currentVariables: object = {}; /** Handle mutation and inject selections + fragments. */ const handleIncomingMutation = (op: Operation) => { @@ -55,303 +62,332 @@ export const populateExchange = ({ return op; } - const activeSelections: TypeFragmentMap = makeDict(); - for (const name in activeTypeFragments) { - activeSelections[name] = activeTypeFragments[name].filter(s => - activeOperations.has(s.key) - ); - } - - const newOperation = makeOperation(op.kind, op); - newOperation.query = addFragmentsToQuery( - schema, - op.query, - activeSelections, - userFragments - ); - - return newOperation; - }; + const document = traverse(op.query, node => { + if (node.kind === Kind.FIELD) { + if (!node.directives) return; - /** Handle query and extract fragments. */ - const handleIncomingQuery = ({ key, kind, query }: Operation) => { - if (kind !== 'query') { - return; - } + const directives = node.directives.filter( + d => getName(d) !== 'populate' + ); - activeOperations.add(key); - if (parsedOperations.has(key)) { - return; - } + if (directives.length === node.directives.length) return; - parsedOperations.add(key); + const field = schema.getMutationType()!.getFields()[node.name.value]; - const [extractedFragments, newFragments] = extractSelectionsFromQuery( - schema, - query - ); + if (!field) return; - for (let i = 0, l = extractedFragments.length; i < l; i++) { - const fragment = extractedFragments[i]; - userFragments[getName(fragment)] = fragment; - } + const type = unwrapType(field.type); - for (let i = 0, l = newFragments.length; i < l; i++) { - const fragment = newFragments[i]; - const type = getName(fragment.typeCondition); - const current = - activeTypeFragments[type] || (activeTypeFragments[type] = []); + if (!type) { + return { + ...node, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + ], + }, + directives, + }; + } - (fragment as any).name.value += current.length; - current.push({ key, fragment }); - } - }; + const visited = new Set(); + const populateSelections = ( + type: GraphQLFlatType, + selections: Array + ) => { + let possibleTypes: readonly string[] = []; + let isAbstract = false; + if (isAbstractType(type)) { + isAbstract = true; + possibleTypes = schema.getPossibleTypes(type).map(x => x.name); + } else { + possibleTypes = [type.name]; + } - const handleIncomingTeardown = ({ key, kind }: Operation) => { - if (kind === 'teardown') { - activeOperations.delete(key); - } - }; + possibleTypes.forEach(typeName => { + const fieldsForType = typeFields.get(typeName); + if (!fieldsForType) { + if (possibleTypes.length === 1) { + selections.push({ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }); + } + return; + } - return ops$ => { - return pipe( - ops$, - tap(handleIncomingQuery), - tap(handleIncomingTeardown), - map(handleIncomingMutation), - forward - ); - }; -}; + let typeSelections: Array< + FieldNode | InlineFragmentNode | FragmentSpreadNode + > = selections; -type UserFragmentMap = Record< - T, - FragmentDefinitionNode ->; + if (isAbstract) { + typeSelections = [ + { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }, + ]; + selections.push({ + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: typeName, + }, + }, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: typeSelections, + }, + }); + } else { + typeSelections.push({ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: '__typename', + }, + }); + } -type TypeFragmentMap = Record; + Object.keys(fieldsForType).forEach(key => { + const value = fieldsForType[key]; + if (value.type instanceof GraphQLScalarType) { + const args = value.args + ? Object.keys(value.args).map(k => { + const v = value.args![k]; + return { + kind: Kind.ARGUMENT, + value: { + kind: v.kind, + value: v.value, + }, + name: { + kind: Kind.NAME, + value: k, + }, + } as ArgumentNode; + }) + : []; + const field: FieldNode = { + kind: Kind.FIELD, + arguments: args, + name: { + kind: Kind.NAME, + value: value.fieldName, + }, + }; + + typeSelections.push(field); + } else if ( + value.type instanceof GraphQLObjectType && + !visited.has(value.type.name) + ) { + visited.add(value.type.name); + const fieldSelections: Array = []; + + populateSelections(value.type, fieldSelections); + + const args = value.args + ? Object.keys(value.args).map(k => { + const v = value.args![k]; + return { + kind: Kind.ARGUMENT, + value: { + kind: v.kind, + value: v.value, + }, + name: { + kind: Kind.NAME, + value: k, + }, + } as ArgumentNode; + }) + : []; + + const field: FieldNode = { + kind: Kind.FIELD, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: fieldSelections, + }, + arguments: args, + name: { + kind: Kind.NAME, + value: value.fieldName, + }, + }; + + typeSelections.push(field); + } + }); + }); + }; -interface TypeFragment { - /** Operation key where selection set is being used. */ - key: number; - /** Selection set. */ - fragment: FragmentDefinitionNode; -} + visited.add(type.name); + const selections: Array< + FieldNode | InlineFragmentNode | FragmentSpreadNode + > = node.selectionSet ? [...node.selectionSet.selections] : []; + populateSelections(type, selections); -/** Gets typed selection sets and fragments from query */ -export const extractSelectionsFromQuery = ( - schema: GraphQLSchema, - query: DocumentNode -) => { - const extractedFragments: FragmentDefinitionNode[] = []; - const newFragments: FragmentDefinitionNode[] = []; - - const sanitizeSelectionSet = ( - selectionSet: SelectionSetNode, - type: string - ) => { - const selections: SelectionNode[] = []; - const validTypes = (schema.getType(type) as GraphQLObjectType).getFields(); - - selectionSet.selections.forEach(selection => { - if (selection.kind === Kind.FIELD) { - if (validTypes[selection.name.value]) { - if (selection.selectionSet) { - selections.push({ - ...selection, - selectionSet: sanitizeSelectionSet( - selection.selectionSet, - unwrapType(validTypes[selection.name.value].type)!.toString() - ), - }); - } else { - selections.push(selection); - } - } - } else { - selections.push(selection); + return { + ...node, + selectionSet: { + kind: Kind.SELECTION_SET, + selections, + }, + directives, + }; } }); - return { ...selectionSet, selections }; + return { + ...op, + query: document, + }; }; - const visits: string[] = []; + const readFromSelectionSet = ( + type: GraphQLObjectType | GraphQLInterfaceType, + selections: readonly SelectionNode[], + seenFields: Record = {} + ) => { + if (isAbstractType(type)) { + // TODO: should we add this to typeParents/typeFields as well? + schema.getPossibleTypes(type).forEach(t => { + readFromSelectionSet(t, selections); + }); + } else { + const fieldMap = type.getFields(); - traverse( - query, - node => { - if (node.kind === Kind.FRAGMENT_DEFINITION) { - extractedFragments.push(node); - } else if (node.kind === Kind.FIELD && node.selectionSet) { - const type = unwrapType( - resolveFields(schema, visits)[node.name.value].type - ); + let args: null | Record = null; + for (let i = 0; i < selections.length; i++) { + const selection = selections[i]; - visits.push(node.name.value); - - if (isAbstractType(type)) { - const types = schema.getPossibleTypes(type); - types.forEach(t => { - newFragments.push({ - kind: Kind.FRAGMENT_DEFINITION, - typeCondition: { - kind: Kind.NAMED_TYPE, - name: createNameNode(t.toString()), - }, - name: createNameNode(`${t.toString()}_PopulateFragment_`), - selectionSet: sanitizeSelectionSet( - node.selectionSet as SelectionSetNode, - t.toString() - ), - }); - }); - } else if (type) { - newFragments.push({ - kind: Kind.FRAGMENT_DEFINITION, - typeCondition: { - kind: Kind.NAMED_TYPE, - name: createNameNode(type.toString()), - }, - name: createNameNode(`${type.toString()}_PopulateFragment_`), - selectionSet: node.selectionSet, - }); - } - } - }, - node => { - if (node.kind === Kind.FIELD && node.selectionSet) visits.pop(); - } - ); + if (selection.kind === Kind.FRAGMENT_SPREAD) { + const fragmentName = getName(selection); - return [extractedFragments, newFragments]; -}; + const fragment = userFragments[fragmentName]; -/** Replaces populate decorator with fragment spreads + fragments. */ -export const addFragmentsToQuery = ( - schema: GraphQLSchema, - query: DocumentNode, - activeTypeFragments: TypeFragmentMap, - userFragments: UserFragmentMap -): DocumentNode => { - const requiredUserFragments: Record< - string, - FragmentDefinitionNode - > = makeDict(); - - const additionalFragments: Record< - string, - FragmentDefinitionNode - > = makeDict(); - - /** Fragments provided and used by the current query */ - const existingFragmentsForQuery: Set = new Set(); - - return traverse( - query, - (node: ASTNode): ASTNode | void => { - if (node.kind === Kind.DOCUMENT) { - node.definitions.reduce((set, definition) => { - if (definition.kind === Kind.FRAGMENT_DEFINITION) { - set.add(definition.name.value); + if (fragment) { + readFromSelectionSet(type, fragment.selectionSet.selections); } - return set; - }, existingFragmentsForQuery); - } else if (node.kind === Kind.FIELD) { - if (!node.directives) return; - - const directives = node.directives.filter( - d => getName(d) !== 'populate' - ); - if (directives.length === node.directives.length) return; + continue; + } - const type = unwrapType( - schema.getMutationType()!.getFields()[node.name.value].type - ); + if (selection.kind === Kind.INLINE_FRAGMENT) { + readFromSelectionSet(type, selection.selectionSet.selections); - let possibleTypes: readonly GraphQLObjectType[] = []; - if (!isCompositeType(type)) { - warn( - 'Invalid type: The type `' + - type + - '` is used with @populate but does not exist.', - 17 - ); - } else { - possibleTypes = isAbstractType(type) - ? schema.getPossibleTypes(type) - : [type]; + continue; } - const newSelections = possibleTypes.reduce((p, possibleType) => { - const typeFrags = activeTypeFragments[possibleType.name]; - if (!typeFrags) { - return p; - } + if (selection.kind !== Kind.FIELD) continue; - for (let i = 0, l = typeFrags.length; i < l; i++) { - const { fragment } = typeFrags[i]; - const fragmentName = getName(fragment); - const usedFragments = getUsedFragmentNames(fragment); + const fieldName = selection.name.value; + if (!fieldMap[fieldName]) continue; - // Add used fragment for insertion at Document node - for (let j = 0, l = usedFragments.length; j < l; j++) { - const name = usedFragments[j]; - if (!existingFragmentsForQuery.has(name)) { - requiredUserFragments[name] = userFragments[name]; - } - } + const ownerType = + seenFields[fieldName] || (seenFields[fieldName] = type); - // Add fragment for insertion at Document node - additionalFragments[fragmentName] = fragment; + let fields = typeFields.get(ownerType.name); + if (!fields) typeFields.set(type.name, (fields = {})); - p.push({ - kind: Kind.FRAGMENT_SPREAD, - name: createNameNode(fragmentName), - }); - } + const childType = unwrapType( + fieldMap[fieldName].type + ) as GraphQLObjectType; - return p; - }, [] as FragmentSpreadNode[]); + if (selection.arguments && selection.arguments.length) { + args = {}; + for (let j = 0; j < selection.arguments.length; j++) { + const argNode = selection.arguments[j]; + args[argNode.name.value] = { + value: valueFromASTUntyped( + argNode.value, + currentVariables as any + ), + kind: argNode.value.kind, + }; + } + } - const existingSelections = getSelectionSet(node); + const fieldKey = args + ? `${fieldName}:${stringifyVariables(args)}` + : fieldName; - const selections = - existingSelections.length || newSelections.length - ? [...newSelections, ...existingSelections] - : [ - { - kind: Kind.FIELD, - name: createNameNode('__typename'), - }, - ]; + if (!fields[fieldKey]) { + fields[fieldKey] = { + type: childType, + args, + fieldName, + }; + } - return { - ...node, - directives, - selectionSet: { - kind: Kind.SELECTION_SET, - selections, - } as SelectionSetNode, - }; + if (selection.selectionSet) { + readFromSelectionSet(childType, selection.selectionSet.selections); + } } - }, - node => { - if (node.kind === Kind.DOCUMENT) { - return { - ...node, - definitions: [ - ...node.definitions, - ...Object.keys(additionalFragments).map( - key => additionalFragments[key] - ), - ...Object.keys(requiredUserFragments).map( - key => requiredUserFragments[key] - ), - ], - }; + } + }; + + /** Handle query and extract fragments. */ + const handleIncomingQuery = ({ key, kind, query, variables }: Operation) => { + if (kind !== 'query') { + return; + } + + activeOperations.add(key); + if (parsedOperations.has(key)) { + return; + } + + parsedOperations.add(key); + currentVariables = variables || {}; + + for (let i = query.definitions.length; i--; ) { + const definition = query.definitions[i]; + + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + userFragments[getName(definition)] = definition; + } else if (definition.kind === Kind.OPERATION_DEFINITION) { + const type = schema.getQueryType()!; + readFromSelectionSet( + unwrapType(type) as GraphQLObjectType, + definition.selectionSet.selections! + ); } } - ); + }; + + const handleIncomingTeardown = ({ key, kind }: Operation) => { + // TODO: we might want to remove fields here, the risk becomes + // that data in the cache would become stale potentially + if (kind === 'teardown') { + activeOperations.delete(key); + } + }; + + return ops$ => { + return pipe( + ops$, + tap(handleIncomingQuery), + tap(handleIncomingTeardown), + map(handleIncomingMutation), + forward + ); + }; };