diff --git a/spec/regression/logistics/tests/update-many-optimized.graphql b/spec/regression/logistics/tests/update-many-optimized.graphql new file mode 100644 index 000000000..b640341bc --- /dev/null +++ b/spec/regression/logistics/tests/update-many-optimized.graphql @@ -0,0 +1,87 @@ +fragment Request on Delivery { + deliveryNumber + consignee { + city + country { + isoCode + description { + languageIsoCode + translation + } + } + street + } + contentInfo { + translation + languageIsoCode + } + destinationCountry { + isoCode + } + dgInfo { + flashpoint + unNumber + notices + } + serialNumbers + items { + itemNumber + } + handlingUnits(orderBy: huNumber_ASC) { + huNumber + } +} + +# triggers a different code path (because there are no relations) +mutation update { + updateDeliveries( + input: [ + { + id: "@{ids/Delivery/1}" + consignee: { city: "Saint Nowhere", country: "DE", street: "Sunrise Avenue 1" } + contentInfo: [ + { languageIsoCode: "de", translation: "für dich" } + { languageIsoCode: "en", translation: "for you" } + ] + destinationCountry: "GB" + dgInfo: { + flashpoint: "37 °C" + unNumber: "123" + notices: ["handle with care", "do not throw"] + } + serialNumbers: ["456", "424242"] + addItems: [{ itemNumber: "44" }, { itemNumber: "45" }] + } + { + id: "@{ids/Delivery/2}" + consignee: { city: "Saint Nowhere", country: "DE", street: "Sunrise Avenue 2" } + contentInfo: [ + { languageIsoCode: "de", translation: "für dich" } + { languageIsoCode: "en", translation: "for you" } + ] + destinationCountry: "GB" + dgInfo: { + flashpoint: "37 °C" + unNumber: "123" + notices: ["do not care", "throw with handle"] + } + serialNumbers: ["456", "424242"] + addItems: [{ itemNumber: "44" }, { itemNumber: "45" }] + } + ] + ) { + ...Request + } +} + +query query { + allDeliveries(orderBy: [deliveryNumber_ASC], first: 1) { + ...Request + } +} + +mutation notFound { + updateDeliveries(input: [{ id: "not-found" }, { id: "also-not-found" }]) { + deliveryNumber + } +} diff --git a/spec/regression/logistics/tests/update-many-optimized.result.json b/spec/regression/logistics/tests/update-many-optimized.result.json new file mode 100644 index 000000000..4ad573347 --- /dev/null +++ b/spec/regression/logistics/tests/update-many-optimized.result.json @@ -0,0 +1,194 @@ +{ + "update": { + "data": { + "updateDeliveries": [ + { + "deliveryNumber": "1000173", + "consignee": { + "city": "Saint Nowhere", + "country": { + "isoCode": "DE", + "description": [ + { + "languageIsoCode": "DE", + "translation": "Deutschland" + }, + { + "languageIsoCode": "EN", + "translation": "Germany" + } + ] + }, + "street": "Sunrise Avenue 1" + }, + "contentInfo": [ + { + "translation": "für dich", + "languageIsoCode": "de" + }, + { + "translation": "for you", + "languageIsoCode": "en" + } + ], + "destinationCountry": { + "isoCode": "GB" + }, + "dgInfo": { + "flashpoint": "37 °C", + "unNumber": "123", + "notices": [ + "handle with care", + "do not throw" + ] + }, + "serialNumbers": [ + "456", + "424242" + ], + "items": [ + { + "itemNumber": "1001" + }, + { + "itemNumber": "1002" + }, + { + "itemNumber": "44" + }, + { + "itemNumber": "45" + } + ], + "handlingUnits": [] + }, + { + "deliveryNumber": "1000521", + "consignee": { + "city": "Saint Nowhere", + "country": { + "isoCode": "DE", + "description": [ + { + "languageIsoCode": "DE", + "translation": "Deutschland" + }, + { + "languageIsoCode": "EN", + "translation": "Germany" + } + ] + }, + "street": "Sunrise Avenue 2" + }, + "contentInfo": [ + { + "translation": "für dich", + "languageIsoCode": "de" + }, + { + "translation": "for you", + "languageIsoCode": "en" + } + ], + "destinationCountry": { + "isoCode": "GB" + }, + "dgInfo": { + "flashpoint": "37 °C", + "unNumber": "123", + "notices": [ + "do not care", + "throw with handle" + ] + }, + "serialNumbers": [ + "456", + "424242" + ], + "items": [ + { + "itemNumber": "2001" + }, + { + "itemNumber": "2002" + }, + { + "itemNumber": "44" + }, + { + "itemNumber": "45" + } + ], + "handlingUnits": [] + } + ] + } + }, + "query": { + "data": { + "allDeliveries": [ + { + "deliveryNumber": "1000173", + "consignee": { + "city": "Saint Nowhere", + "country": { + "isoCode": "DE", + "description": [ + { + "languageIsoCode": "DE", + "translation": "Deutschland" + }, + { + "languageIsoCode": "EN", + "translation": "Germany" + } + ] + }, + "street": "Sunrise Avenue 1" + }, + "contentInfo": [ + { + "translation": "für dich", + "languageIsoCode": "de" + }, + { + "translation": "for you", + "languageIsoCode": "en" + } + ], + "destinationCountry": { + "isoCode": "GB" + }, + "dgInfo": { + "flashpoint": "37 °C", + "unNumber": "123", + "notices": [ + "handle with care", + "do not throw" + ] + }, + "serialNumbers": [ + "456", + "424242" + ], + "items": [ + { + "itemNumber": "1001" + }, + { + "itemNumber": "1002" + }, + { + "itemNumber": "44" + }, + { + "itemNumber": "45" + } + ], + "handlingUnits": [] + } + ] + } + } +} \ No newline at end of file diff --git a/spec/regression/logistics/tests/update-many.graphql b/spec/regression/logistics/tests/update-many.graphql index 64fa565ca..b22b121d7 100644 --- a/spec/regression/logistics/tests/update-many.graphql +++ b/spec/regression/logistics/tests/update-many.graphql @@ -32,7 +32,7 @@ fragment Request on Delivery { } } -mutation create { +mutation update { updateDeliveries( input: [ { @@ -82,3 +82,9 @@ query query { ...Request } } + +mutation notFound { + updateDeliveries(input: [{ id: "not-found" }, { id: "also-not-found" }]) { + deliveryNumber + } +} diff --git a/spec/regression/logistics/tests/update-many.result.json b/spec/regression/logistics/tests/update-many.result.json index 838d29fe5..cb60e8eef 100644 --- a/spec/regression/logistics/tests/update-many.result.json +++ b/spec/regression/logistics/tests/update-many.result.json @@ -1,5 +1,5 @@ { - "create": { + "update": { "data": { "updateDeliveries": [ { diff --git a/src/schema-generation/mutation-type-generator.ts b/src/schema-generation/mutation-type-generator.ts index 8543309e3..1e6ee1768 100644 --- a/src/schema-generation/mutation-type-generator.ts +++ b/src/schema-generation/mutation-type-generator.ts @@ -1,17 +1,20 @@ import { GraphQLID, GraphQLList, GraphQLNonNull } from 'graphql'; import { flatMap } from 'lodash'; import memorize from 'memorize-decorator'; -import { Namespace, RootEntityType } from '../model'; +import { AggregationOperator, Namespace, RootEntityType } from '../model'; import { AffectedFieldInfoQueryNode, + AggregationQueryNode, BinaryOperationQueryNode, BinaryOperator, + CountQueryNode, CreateBillingEntityQueryNode, DeleteEntitiesQueryNode, EntitiesIdentifierKind, EntitiesQueryNode, EntityFromIdQueryNode, ErrorIfEmptyResultValidator, + ErrorIfNotTruthyResultValidator, FirstOfListQueryNode, ListQueryNode, LiteralQueryNode, @@ -258,9 +261,44 @@ export class MutationTypeGenerator { ids.add(inputID); } - const statements = flatMap(inputs, (input) => - this.getUpdateStatements(rootEntityType, input, inputType, fieldContext), - ); + // Optimization: see if we need any statements except the main ones. If we don't, we can + // combine everything into one statement + let needsSeparateStatements = false; + let statements: PreExecQueryParms[] = []; + let updateEntityNodes: QueryNode[] = []; + for (const input of inputs) { + const result = this.getUpdateStatements(rootEntityType, input, inputType, fieldContext); + statements.push(...result.statements); + if (result.needsStatements) { + needsSeparateStatements = true; + } + updateEntityNodes.push(result.updateEntityNode); + } + if (!needsSeparateStatements && statements.length > 1) { + // can combine everything into one statement. we need to add a validator to check if + // every object was found, though. + const zeroNode = new LiteralQueryNode(0); + // list of booleans with true for "found" and false for "not found" + const listNode = new ListQueryNode( + updateEntityNodes.map( + (node) => + new BinaryOperationQueryNode( + new CountQueryNode(node), + BinaryOperator.GREATER_THAN, + zeroNode, + ), + ), + ); + const combinedStatement = new PreExecQueryParms({ + query: new AggregationQueryNode(listNode, AggregationOperator.EVERY_TRUE), + resultValidator: new ErrorIfNotTruthyResultValidator({ + errorMessage: `At least one of the ${rootEntityType.name} objects to update could not be found.`, + errorCode: NOT_FOUND_ERROR, + }), + }); + statements = [combinedStatement]; + } + const resultNode = new ListQueryNode( inputs.map( (input) => @@ -310,7 +348,12 @@ export class MutationTypeGenerator { return checkResult; } - const statements = this.getUpdateStatements(rootEntityType, input, inputType, fieldContext); + const { statements } = this.getUpdateStatements( + rootEntityType, + input, + inputType, + fieldContext, + ); // PreExecute creation and relation queries and return result return new WithPreExecutionQueryNode({ @@ -327,7 +370,94 @@ export class MutationTypeGenerator { input: PlainObject, inputType: UpdateRootEntityInputType, fieldContext: FieldContext, - ): ReadonlyArray { + ): { + readonly statements: ReadonlyArray; + readonly updateEntityNode: QueryNode; + readonly needsStatements: boolean; + } { + const updateEntityNode = this.getUpdateRootEntityQueryNode( + fieldContext, + inputType, + input, + rootEntityType, + ); + const updatedIdsVarNode = new VariableQueryNode('updatedIds'); + const updateEntityPreExec = new PreExecQueryParms({ + query: updateEntityNode, + resultVariable: updatedIdsVarNode, + resultValidator: new ErrorIfEmptyResultValidator({ + errorMessage: `${rootEntityType.name} with id '${input[ID_FIELD]}' could not be found.`, + errorCode: NOT_FOUND_ERROR, + }), + }); + + const relationStatements = inputType.getRelationStatements( + input, + new FirstOfListQueryNode(updatedIdsVarNode), + fieldContext, + ); + const billingStatement = this.getBillingStatementForUpdate( + rootEntityType, + input, + updatedIdsVarNode, + ); + + const statements = [ + updateEntityPreExec, + ...relationStatements, + ...(billingStatement ? [billingStatement] : []), + ]; + + return { + statements, + needsStatements: statements.length > 1, + updateEntityNode, + }; + } + + private getBillingStatementForUpdate( + rootEntityType: RootEntityType, + input: PlainObject, + updatedIdsVarNode: VariableQueryNode, + ): PreExecQueryParms | undefined { + if ( + !rootEntityType.billingEntityConfig || + !rootEntityType.billingEntityConfig.keyFieldName || + !input[rootEntityType.billingEntityConfig.keyFieldName] + ) { + return; + } + + const entityVar = new VariableQueryNode('entity'); + return new PreExecQueryParms({ + query: new VariableAssignmentQueryNode({ + variableValueNode: new EntityFromIdQueryNode( + rootEntityType, + new FirstOfListQueryNode(updatedIdsVarNode), + ), + variableNode: entityVar, + resultNode: new CreateBillingEntityQueryNode({ + rootEntityTypeName: rootEntityType.name, + key: input[rootEntityType.billingEntityConfig.keyFieldName] as number | string, + categoryNode: createBillingEntityCategoryNode( + rootEntityType.billingEntityConfig, + entityVar, + ), + quantityNode: createBillingEntityQuantityNode( + rootEntityType.billingEntityConfig, + entityVar, + ), + }), + }), + }); + } + + private getUpdateRootEntityQueryNode( + fieldContext: FieldContext, + inputType: UpdateRootEntityInputType, + input: PlainObject, + rootEntityType: RootEntityType, + ) { const currentEntityVariable = new VariableQueryNode('currentEntity'); const context: UpdateInputFieldContext = { ...fieldContext, @@ -352,7 +482,7 @@ export class MutationTypeGenerator { itemVariable: listItemVar, }); - const updateEntityNode = new UpdateEntitiesQueryNode({ + return new UpdateEntitiesQueryNode({ rootEntityType, affectedFields, updates, @@ -360,57 +490,6 @@ export class MutationTypeGenerator { listNode, revision, }); - const updatedIdsVarNode = new VariableQueryNode('updatedIds'); - const updateEntityPreExec = new PreExecQueryParms({ - query: updateEntityNode, - resultVariable: updatedIdsVarNode, - resultValidator: new ErrorIfEmptyResultValidator({ - errorMessage: `${rootEntityType.name} with id '${input[ID_FIELD]}' could not be found.`, - errorCode: NOT_FOUND_ERROR, - }), - }); - - const relationStatements = inputType.getRelationStatements( - input, - new FirstOfListQueryNode(updatedIdsVarNode), - context, - ); - - const preExecQueryParms = [updateEntityPreExec, ...relationStatements]; - if ( - rootEntityType.billingEntityConfig && - rootEntityType.billingEntityConfig.keyFieldName && - input[rootEntityType.billingEntityConfig.keyFieldName] - ) { - const entityVar = new VariableQueryNode('entity'); - preExecQueryParms.push( - new PreExecQueryParms({ - query: new VariableAssignmentQueryNode({ - variableValueNode: new EntityFromIdQueryNode( - rootEntityType, - new FirstOfListQueryNode(updatedIdsVarNode), - ), - variableNode: entityVar, - resultNode: new CreateBillingEntityQueryNode({ - rootEntityTypeName: rootEntityType.name, - key: input[rootEntityType.billingEntityConfig.keyFieldName] as - | number - | string, - categoryNode: createBillingEntityCategoryNode( - rootEntityType.billingEntityConfig, - entityVar, - ), - quantityNode: createBillingEntityQuantityNode( - rootEntityType.billingEntityConfig, - entityVar, - ), - }), - }), - }), - ); - } - - return preExecQueryParms; } private generateUpdateAllField(rootEntityType: RootEntityType): QueryNodeField | undefined {