From 3713622cfc7e47104cd215f7afd7a86e141cf147 Mon Sep 17 00:00:00 2001 From: Jan Melcher Date: Tue, 7 Nov 2023 18:54:52 +0100 Subject: [PATCH] perf: improve performance of bulk child entity updates and avoid hitting the AQL limit of 500 nesting limit doing it Child entity updates generate a lot of AQL because each entity can have different kinds of updates, and some of them refer to old values. This is still the case after the optimizations. However, previously, we performed multiple child entity updates by nesting conditional expressions like this: items = items.map(item => item.id == 2 ? update2(item) : (item.id == 1 ? update1(item) : item) ); This generated one level of nesting per update. ArangoDB 3.11 now limits the AQL nesting to 500, which means we would be limited to a little under 500 item updates in one mutation. In addition, a few hundred updates had really bad performance. Now, we convert the list into a dictionary (id -> entity), so we can more efficiently look up items and construct the new list. This optimization is only applied after a threshold of 3 (configurable) because it involves some steps, and doing a single or two updates is probably still faster with the conditionals. --- .../regression/logistics/default-context.json | 3 +- .../update-child-entities-dict.context.json | 4 + .../tests/update-child-entities-dict.graphql | 120 ++++++ .../update-child-entities-dict.result.json | 342 ++++++++++++++++++ .../tests/update-child-entities.context.json | 4 + .../tests/update-child-entities.graphql | 3 + spec/regression/regression-suite.ts | 2 + src/database/arangodb/aql-generator.ts | 51 +++ src/database/inmemory/js-generator.ts | 63 ++++ src/execution/execution-options.ts | 8 + src/execution/operation-resolver.ts | 2 + src/query-tree/child-entities.ts | 90 +++++ src/query-tree/index.ts | 1 + .../query-node-object-type/context.ts | 8 + .../update-input-types/input-types.ts | 166 +++++++-- 15 files changed, 830 insertions(+), 37 deletions(-) create mode 100644 spec/regression/logistics/tests/update-child-entities-dict.context.json create mode 100644 spec/regression/logistics/tests/update-child-entities-dict.graphql create mode 100644 spec/regression/logistics/tests/update-child-entities-dict.result.json create mode 100644 spec/regression/logistics/tests/update-child-entities.context.json create mode 100644 src/query-tree/child-entities.ts diff --git a/spec/regression/logistics/default-context.json b/spec/regression/logistics/default-context.json index a55b8cb5..aab43427 100644 --- a/spec/regression/logistics/default-context.json +++ b/spec/regression/logistics/default-context.json @@ -1,3 +1,4 @@ { - "authRoles": ["allusers"] + "authRoles": ["allusers"], + "childEntityUpdatesViaDictStrategyThreshold": 2 } diff --git a/spec/regression/logistics/tests/update-child-entities-dict.context.json b/spec/regression/logistics/tests/update-child-entities-dict.context.json new file mode 100644 index 00000000..7d092d39 --- /dev/null +++ b/spec/regression/logistics/tests/update-child-entities-dict.context.json @@ -0,0 +1,4 @@ +{ + "childEntityUpdatesViaDictStrategyThreshold": 1, + "authRoles": ["allusers"] +} diff --git a/spec/regression/logistics/tests/update-child-entities-dict.graphql b/spec/regression/logistics/tests/update-child-entities-dict.graphql new file mode 100644 index 00000000..0d6ddabb --- /dev/null +++ b/spec/regression/logistics/tests/update-child-entities-dict.graphql @@ -0,0 +1,120 @@ +# in the .context.json, childEntityUpdatesViaDictStrategyThreshold is set to 1 +# so we will always use the dict strategy to update child entities + +mutation updateOne { + updateDelivery( + input: { + id: "@{ids/Delivery/1}" + updateItems: [{ id: "id_init_0000", itemNumber: "updated1" }] + } + ) { + items { + id + itemNumber + } + } +} + +# to verify it is updated in the db +query afterUpdateOne { + Delivery(id: "@{ids/Delivery/1}") { + items { + id + itemNumber + } + } +} + +mutation addSome { + updateDelivery( + input: { + id: "@{ids/Delivery/1}" + addItems: [ + { itemNumber: "added00" } + { itemNumber: "added01" } + { itemNumber: "added02" } + { itemNumber: "added03" } + { itemNumber: "added04" } + { itemNumber: "added05" } + { itemNumber: "added06" } + { itemNumber: "added07" } + { itemNumber: "added08" } + { itemNumber: "added09" } + { itemNumber: "added10" } + ] + } + ) { + items { + id + itemNumber + } + } +} + +mutation updateMultiple { + updateDelivery( + input: { + id: "@{ids/Delivery/1}" + updateItems: [ + { id: "id_test_0003", itemNumber: "updated03" } + { id: "id_test_0005", itemNumber: "updated05" } + { id: "id_test_0007", itemNumber: "updated07" } + { id: "id_test_0008", itemNumber: "updated08" } + { id: "id_test_0009", itemNumber: "updated09" } + ] + } + ) { + items { + id + itemNumber + } + } +} + +# to verify it is updated in the db +query afterUpdateMultiple { + Delivery(id: "@{ids/Delivery/1}") { + items { + id + itemNumber + } + } +} + +mutation addUpdateAndDelete { + updateDelivery( + input: { + id: "@{ids/Delivery/1}" + addItems: [ + { itemNumber: "finalNew1" } + { itemNumber: "finalNew2" } + { itemNumber: "finalNew3" } + ] + updateItems: [ + { id: "id_test_0004", itemNumber: "finalUpdated04" } + # this is finalNew2 + { id: "id_test_0012", itemNumber: "finalUpdated02" } + ] + removeItems: [ + "id_test_0007" + # this is finalNew3 + "id_test_0013" + ] + } + ) { + items { + id + itemNumber + } + } +} + +# to verify it is updated in the db +query end { + Delivery(id: "@{ids/Delivery/1}") { + items { + id + itemNumber + } + } +} diff --git a/spec/regression/logistics/tests/update-child-entities-dict.result.json b/spec/regression/logistics/tests/update-child-entities-dict.result.json new file mode 100644 index 00000000..92e39914 --- /dev/null +++ b/spec/regression/logistics/tests/update-child-entities-dict.result.json @@ -0,0 +1,342 @@ +{ + "updateOne": { + "data": { + "updateDelivery": { + "items": [ + { + "id": "id_init_0000", + "itemNumber": "updated1" + }, + { + "id": "id_init_0001", + "itemNumber": "1002" + } + ] + } + } + }, + "afterUpdateOne": { + "data": { + "Delivery": { + "items": [ + { + "id": "id_init_0000", + "itemNumber": "updated1" + }, + { + "id": "id_init_0001", + "itemNumber": "1002" + } + ] + } + } + }, + "addSome": { + "data": { + "updateDelivery": { + "items": [ + { + "id": "id_init_0000", + "itemNumber": "updated1" + }, + { + "id": "id_init_0001", + "itemNumber": "1002" + }, + { + "id": "id_test_0000", + "itemNumber": "added00" + }, + { + "id": "id_test_0001", + "itemNumber": "added01" + }, + { + "id": "id_test_0002", + "itemNumber": "added02" + }, + { + "id": "id_test_0003", + "itemNumber": "added03" + }, + { + "id": "id_test_0004", + "itemNumber": "added04" + }, + { + "id": "id_test_0005", + "itemNumber": "added05" + }, + { + "id": "id_test_0006", + "itemNumber": "added06" + }, + { + "id": "id_test_0007", + "itemNumber": "added07" + }, + { + "id": "id_test_0008", + "itemNumber": "added08" + }, + { + "id": "id_test_0009", + "itemNumber": "added09" + }, + { + "id": "id_test_0010", + "itemNumber": "added10" + } + ] + } + } + }, + "updateMultiple": { + "data": { + "updateDelivery": { + "items": [ + { + "id": "id_init_0000", + "itemNumber": "updated1" + }, + { + "id": "id_init_0001", + "itemNumber": "1002" + }, + { + "id": "id_test_0000", + "itemNumber": "added00" + }, + { + "id": "id_test_0001", + "itemNumber": "added01" + }, + { + "id": "id_test_0002", + "itemNumber": "added02" + }, + { + "id": "id_test_0003", + "itemNumber": "updated03" + }, + { + "id": "id_test_0004", + "itemNumber": "added04" + }, + { + "id": "id_test_0005", + "itemNumber": "updated05" + }, + { + "id": "id_test_0006", + "itemNumber": "added06" + }, + { + "id": "id_test_0007", + "itemNumber": "updated07" + }, + { + "id": "id_test_0008", + "itemNumber": "updated08" + }, + { + "id": "id_test_0009", + "itemNumber": "updated09" + }, + { + "id": "id_test_0010", + "itemNumber": "added10" + } + ] + } + } + }, + "afterUpdateMultiple": { + "data": { + "Delivery": { + "items": [ + { + "id": "id_init_0000", + "itemNumber": "updated1" + }, + { + "id": "id_init_0001", + "itemNumber": "1002" + }, + { + "id": "id_test_0000", + "itemNumber": "added00" + }, + { + "id": "id_test_0001", + "itemNumber": "added01" + }, + { + "id": "id_test_0002", + "itemNumber": "added02" + }, + { + "id": "id_test_0003", + "itemNumber": "updated03" + }, + { + "id": "id_test_0004", + "itemNumber": "added04" + }, + { + "id": "id_test_0005", + "itemNumber": "updated05" + }, + { + "id": "id_test_0006", + "itemNumber": "added06" + }, + { + "id": "id_test_0007", + "itemNumber": "updated07" + }, + { + "id": "id_test_0008", + "itemNumber": "updated08" + }, + { + "id": "id_test_0009", + "itemNumber": "updated09" + }, + { + "id": "id_test_0010", + "itemNumber": "added10" + } + ] + } + } + }, + "addUpdateAndDelete": { + "data": { + "updateDelivery": { + "items": [ + { + "id": "id_init_0000", + "itemNumber": "updated1" + }, + { + "id": "id_init_0001", + "itemNumber": "1002" + }, + { + "id": "id_test_0000", + "itemNumber": "added00" + }, + { + "id": "id_test_0001", + "itemNumber": "added01" + }, + { + "id": "id_test_0002", + "itemNumber": "added02" + }, + { + "id": "id_test_0003", + "itemNumber": "updated03" + }, + { + "id": "id_test_0004", + "itemNumber": "finalUpdated04" + }, + { + "id": "id_test_0005", + "itemNumber": "updated05" + }, + { + "id": "id_test_0006", + "itemNumber": "added06" + }, + { + "id": "id_test_0008", + "itemNumber": "updated08" + }, + { + "id": "id_test_0009", + "itemNumber": "updated09" + }, + { + "id": "id_test_0010", + "itemNumber": "added10" + }, + { + "id": "id_test_0011", + "itemNumber": "finalNew1" + }, + { + "id": "id_test_0012", + "itemNumber": "finalUpdated02" + } + ] + } + } + }, + "end": { + "data": { + "Delivery": { + "items": [ + { + "id": "id_init_0000", + "itemNumber": "updated1" + }, + { + "id": "id_init_0001", + "itemNumber": "1002" + }, + { + "id": "id_test_0000", + "itemNumber": "added00" + }, + { + "id": "id_test_0001", + "itemNumber": "added01" + }, + { + "id": "id_test_0002", + "itemNumber": "added02" + }, + { + "id": "id_test_0003", + "itemNumber": "updated03" + }, + { + "id": "id_test_0004", + "itemNumber": "finalUpdated04" + }, + { + "id": "id_test_0005", + "itemNumber": "updated05" + }, + { + "id": "id_test_0006", + "itemNumber": "added06" + }, + { + "id": "id_test_0008", + "itemNumber": "updated08" + }, + { + "id": "id_test_0009", + "itemNumber": "updated09" + }, + { + "id": "id_test_0010", + "itemNumber": "added10" + }, + { + "id": "id_test_0011", + "itemNumber": "finalNew1" + }, + { + "id": "id_test_0012", + "itemNumber": "finalUpdated02" + } + ] + } + } + } +} \ No newline at end of file diff --git a/spec/regression/logistics/tests/update-child-entities.context.json b/spec/regression/logistics/tests/update-child-entities.context.json new file mode 100644 index 00000000..8c778a06 --- /dev/null +++ b/spec/regression/logistics/tests/update-child-entities.context.json @@ -0,0 +1,4 @@ +{ + "childEntityUpdatesViaDictStrategyThreshold": 100, + "authRoles": ["allusers"] +} diff --git a/spec/regression/logistics/tests/update-child-entities.graphql b/spec/regression/logistics/tests/update-child-entities.graphql index a3960bf8..da597ea5 100644 --- a/spec/regression/logistics/tests/update-child-entities.graphql +++ b/spec/regression/logistics/tests/update-child-entities.graphql @@ -1,3 +1,6 @@ +# in the .context.json, childEntityUpdatesViaDictStrategyThreshold is set to 100 +# so we will never use the dict strategy to update child entities + mutation updateOne { updateDelivery( input: { diff --git a/spec/regression/regression-suite.ts b/spec/regression/regression-suite.ts index a09f8a6c..1fb095f9 100644 --- a/spec/regression/regression-suite.ts +++ b/spec/regression/regression-suite.ts @@ -74,6 +74,8 @@ export class RegressionSuite { authContext: { authRoles: context.authRoles, claims: context.claims }, flexSearchMaxFilterableAndSortableAmount: context.flexSearchMaxFilterableAndSortableAmount, + childEntityUpdatesViaDictStrategyThreshold: + context.childEntityUpdatesViaDictStrategyThreshold, idGenerator: this.idGenerator, }), modelOptions: { diff --git a/src/database/arangodb/aql-generator.ts b/src/database/arangodb/aql-generator.ts index fd5b8f41..cc28b3db 100644 --- a/src/database/arangodb/aql-generator.ts +++ b/src/database/arangodb/aql-generator.ts @@ -56,6 +56,7 @@ import { TypeCheckQueryNode, UnaryOperationQueryNode, UnaryOperator, + UpdateChildEntitiesQueryNode, UpdateEntitiesQueryNode, VariableAssignmentQueryNode, VariableQueryNode, @@ -807,6 +808,56 @@ register(AggregationQueryNode, (node, context) => { ); }); +register(UpdateChildEntitiesQueryNode, (node, context) => { + const itemsVar = aql.variable('items'); + const itemsWithIndexVar = aql.variable('itemsWithIndex'); + const childContext = context.introduceVariable(node.dictionaryVar); + const dictVar = childContext.getVariable(node.dictionaryVar); + const updatedDictVar = aql.variable('updatedDict'); + const itemVar = aql.variable('item'); + const indexVar = aql.variable('indexVar'); + + return aqlExt.parenthesizeList( + // could be a complex expression, and we're using it multiple times -> store in a variable + aql`LET ${itemsVar} = ${processNode(node.originalList, context)}`, + + // add a __index property to each item so we can sort by this later + // regular field names cannot start with an underscore, so we're safe to use __index as a + // temporary property to store the index of the child entity in the list + aql`LET ${itemsWithIndexVar} = ${aqlExt.parenthesizeList( + aql`FOR ${indexVar}`, + aql`IN 0..(LENGTH(${itemsVar}) - 1)`, + aql`RETURN MERGE(NTH(${itemsVar}, ${indexVar}), { __index: ${indexVar} })`, + )}`, + + // convert the list into a dict object like { 'id1': { ...}, 'id2': { ... } } + // this allows us to efficiently look up individual objects (to avoid quadratic runtime) + aql`LET ${dictVar} = ZIP(${itemsVar}[*].id, ${itemsWithIndexVar})`, + + // merging the updated items into the dict to remove the old versions of the updated items + aql`LET ${updatedDictVar} = MERGE(${dictVar}, {`, + aql.indent( + aql.join( + node.updates.map((update): AQLFragment => { + const idFrag = processNode(update.idNode, childContext); + // we're expecting the newChildEntityNode to merge the untouched properties of + // the old item, including __index + const valueFrag = processNode(update.newChildEntityNode, childContext); + return aql`${idFrag}: ${valueFrag}`; + }), + aql`,\n`, + ), + ), + aql`})`, + + // sort by the __index we stored, and unpack the dictionary into a list again + aql`FOR ${itemVar}`, + aql`IN VALUES(${updatedDictVar})`, + aql`SORT ${itemVar}.__index`, + aql`RETURN UNSET(${itemVar}, '__index')`, + ); +}); + register(MergeObjectsQueryNode, (node, context) => { const objectList = node.objectNodes.map((node) => processNode(node, context)); const objectsFragment = aql.join(objectList, aql`, `); diff --git a/src/database/inmemory/js-generator.ts b/src/database/inmemory/js-generator.ts index ac7ab926..f3c3d840 100644 --- a/src/database/inmemory/js-generator.ts +++ b/src/database/inmemory/js-generator.ts @@ -53,6 +53,7 @@ import { TypeCheckQueryNode, UnaryOperationQueryNode, UnaryOperator, + UpdateChildEntitiesQueryNode, UpdateEntitiesQueryNode, VariableAssignmentQueryNode, VariableQueryNode, @@ -508,6 +509,68 @@ register(AggregationQueryNode, (node, context) => { } }); +register(UpdateChildEntitiesQueryNode, (node, context) => { + if (!node.updates.length) { + // optimization, and we later rely on updates.length >= 1 + return processNode(node.originalList, context); + } + + const itemsVar = js.variable('items'); + const childContext = context.introduceVariable(node.dictionaryVar); + const dictVar = childContext.getVariable(node.dictionaryVar); + const updatedDictVar = js.variable('updatedDict'); + const itemVar = js.variable('item'); + const indexVar = js.variable('indexVar'); + + // this is deliberately close to the aql implementation + + return jsExt.executingFunction( + // could be a complex expression, and we're using it multiple times -> store in a variable + js`const ${itemsVar} = ${processNode(node.originalList, context)}`, + + // add a __index property to each item so we can sort by this later + // regular field names cannot start with an underscore, so we're safe to use __index as a + // temporary property to store the index of the child entity in the list + // convert the list into a dict object like { 'id1': { ...}, 'id2': { ... } } + // this allows us to efficiently look up individual objects (to avoid quadratic runtime) + js`const ${dictVar} = Object.fromEntries(`, + js.indent( + js.lines( + js`${itemsVar}.map((${itemVar}, ${indexVar}) => [`, + js.indent( + js.lines( + // id as key, item with __index as value + js`${itemVar}.id,`, + js`{ ...${itemVar}, __index: ${indexVar} }`, + ), + ), + js`])`, + ), + ), + js`);`, + + // merging the updated items into the dict to remove the old versions of the updated items + js`const ${updatedDictVar} = {`, + js.indent( + js.lines( + js`...${dictVar},`, + ...node.updates.map((update): JSFragment => { + const idFrag = processNode(update.idNode, childContext); + // we're expecting the newChildEntityNode to merge the untouched properties of + // the old item, including __index + // using [idFrag] because it's a bound value, not an identifier + const valueFrag = processNode(update.newChildEntityNode, childContext); + return js`[${idFrag}]: ${valueFrag},`; + }), + ), + ), + js`};`, + + // sort by the __index we stored, and unpack the dictionary into a list again + js`return Object.values(${updatedDictVar}).map(({ __index, ...${itemVar} }) => ${itemVar});`, + ); +}); + register(MergeObjectsQueryNode, (node, context) => { const objectList = node.objectNodes.map((node) => processNode(node, context)); const objectsFragment = js.join(objectList, js`, `); diff --git a/src/execution/execution-options.ts b/src/execution/execution-options.ts index 1594cae9..60e41de7 100644 --- a/src/execution/execution-options.ts +++ b/src/execution/execution-options.ts @@ -68,6 +68,14 @@ export interface ExecutionOptions { */ readonly flexSearchRecursionDepth?: number; + /** + * A child entity update operation with this number of updates or more will use the "dict" + * strategy that converts the list into a dictionary before applying the updates first + * + * If not specified, a reasonable default will be used + */ + readonly childEntityUpdatesViaDictStrategyThreshold?: number; + readonly timeToLiveOptions?: TimeToLiveExecutionOptions; /** diff --git a/src/execution/operation-resolver.ts b/src/execution/operation-resolver.ts index d8d9b722..e875d845 100644 --- a/src/execution/operation-resolver.ts +++ b/src/execution/operation-resolver.ts @@ -100,6 +100,8 @@ export class OperationResolver { flexSearchMaxFilterableAmountOverride: options.flexSearchMaxFilterableAndSortableAmount, flexSearchRecursionDepth: options.flexSearchRecursionDepth, + childEntityUpdatesViaDictStrategyThreshold: + options.childEntityUpdatesViaDictStrategyThreshold, clock: options.clock ?? new DefaultClock(), idGenerator: options.idGenerator ?? new UUIDGenerator(), }; diff --git a/src/query-tree/child-entities.ts b/src/query-tree/child-entities.ts new file mode 100644 index 00000000..5187f937 --- /dev/null +++ b/src/query-tree/child-entities.ts @@ -0,0 +1,90 @@ +import { QueryNode } from './base'; +import { VariableQueryNode } from './variables'; +import { indent } from '../utils/utils'; + +export interface UpdateChildEntitiesQueryNodeParams { + /** + * The original list of child entities + */ + readonly originalList: QueryNode; + + /** + * A variable to use as a dictionary for existing child entities + * + * This variable will be set to an object like { id1: childEntity1, id2: childEntity2 }. The + * updateQueryNode can use a DynamicPropertyAccessQueryNode(dictionaryVar, id) to access the old + * child entity + */ + readonly dictionaryVar: VariableQueryNode; + + /** + * The list of child entity updates + * + * A PropertyAccessQueryNode(dictionaryVar, id) can be used to access the old value for the + * respective DynamicPropertyAccessQueryNode entity + */ + readonly updates: ReadonlyArray; +} + +export interface ChildEntityUpdate { + /** + * A node that evaluates to the child entity id + */ + readonly idNode: QueryNode; + + /** + * A node that evaluates to the new value of the child entity + */ + readonly newChildEntityNode: QueryNode; +} + +/** + * Updates multiple objects in a list, each identified by an "id" field + */ +export class UpdateChildEntitiesQueryNode extends QueryNode { + /** + * The original list of child entities + */ + readonly originalList: QueryNode; + + /** + * A variable to use as a dictionary for existing child entities + * + * This variable will be set to an object like { id1: childEntity1, id2: childEntity2 }. The + * updateQueryNode can use a DynamicPropertyAccessQueryNode(dictionaryVar, id) to access the old + * child entity + */ + readonly dictionaryVar: VariableQueryNode; + + /** + * The list of child entity updates + * + * A PropertyAccessQueryNode(dictionaryVar, id) can be used to access the old value for the + * respective DynamicPropertyAccessQueryNode entity + */ + readonly updates: ReadonlyArray; + + constructor(params: UpdateChildEntitiesQueryNodeParams) { + super(); + this.originalList = params.originalList; + this.dictionaryVar = params.dictionaryVar; + this.updates = params.updates; + } + + describe() { + return ( + `update child entity list (\n` + + indent(this.originalList.describe()) + + '\n)' + + `with the following updates:\n` + + indent( + this.updates + .map( + (update) => + `${update.idNode.describe()}: ${update.newChildEntityNode.describe()}`, + ) + .join('\n'), + ) + ); + } +} diff --git a/src/query-tree/index.ts b/src/query-tree/index.ts index 20da6f11..fb1b53ed 100644 --- a/src/query-tree/index.ts +++ b/src/query-tree/index.ts @@ -12,5 +12,6 @@ export * from './validation'; export * from './variables'; export { FlexSearchStartsWithQueryNode } from './flex-search'; export * from './billing'; +export * from './child-entities'; // visitor is intentionally not re-exported as it can be seen as an 'add-on' diff --git a/src/schema-generation/query-node-object-type/context.ts b/src/schema-generation/query-node-object-type/context.ts index 08864b67..41965f8f 100644 --- a/src/schema-generation/query-node-object-type/context.ts +++ b/src/schema-generation/query-node-object-type/context.ts @@ -19,6 +19,14 @@ export interface FieldContext { readonly flexSearchMaxFilterableAmountOverride?: number; readonly flexSearchRecursionDepth?: number; + /** + * A child entity update operation with this number of updates or more will use the "dict" + * strategy that converts the list into a dictionary before applying the updates first + * + * If not specified, a reasonable default will be used + */ + readonly childEntityUpdatesViaDictStrategyThreshold?: number; + /** * A stack of objects that correspond to the selections that are intended to be used with WeakMaps to store * additional information diff --git a/src/schema-generation/update-input-types/input-types.ts b/src/schema-generation/update-input-types/input-types.ts index f7ab7acb..e292dc42 100644 --- a/src/schema-generation/update-input-types/input-types.ts +++ b/src/schema-generation/update-input-types/input-types.ts @@ -1,15 +1,60 @@ import { GraphQLID, GraphQLInputFieldConfigMap } from 'graphql'; import { ThunkReadonlyArray } from 'graphql/type/definition'; import { groupBy } from 'lodash'; -import { ChildEntityType, EntityExtensionType, Field, ObjectType, RootEntityType } from '../../model'; -import { BinaryOperationQueryNode, BinaryOperator, ConcatListsQueryNode, ConditionalQueryNode, FieldQueryNode, LiteralQueryNode, MergeObjectsQueryNode, ObjectQueryNode, PreExecQueryParms, QueryNode, RuntimeErrorQueryNode, SafeListQueryNode, SetFieldQueryNode, TransformListQueryNode, UnaryOperationQueryNode, UnaryOperator, VariableQueryNode } from '../../query-tree'; +import { + ChildEntityType, + EntityExtensionType, + Field, + ObjectType, + RootEntityType, +} from '../../model'; +import { + BinaryOperationQueryNode, + BinaryOperator, + ChildEntityUpdate, + ConcatListsQueryNode, + ConditionalQueryNode, + DynamicPropertyAccessQueryNode, + FieldQueryNode, + LiteralQueryNode, + MergeObjectsQueryNode, + ObjectQueryNode, + PreExecQueryParms, + QueryNode, + RuntimeErrorQueryNode, + SafeListQueryNode, + SetFieldQueryNode, + TransformListQueryNode, + UnaryOperationQueryNode, + UnaryOperator, + UpdateChildEntitiesQueryNode, + VariableQueryNode, +} from '../../query-tree'; import { ENTITY_UPDATED_AT, ID_FIELD, REVISION_FIELD } from '../../schema/constants'; -import { getAddChildEntitiesFieldName, getRemoveChildEntitiesFieldName, getReplaceChildEntitiesFieldName, getUpdateChildEntitiesFieldName } from '../../schema/names'; -import { AnyValue, decapitalize, flatMap, joinWithAnd, objectEntries, PlainObject } from '../../utils/utils'; +import { + getAddChildEntitiesFieldName, + getRemoveChildEntitiesFieldName, + getReplaceChildEntitiesFieldName, + getUpdateChildEntitiesFieldName, +} from '../../schema/names'; +import { + AnyValue, + decapitalize, + flatMap, + joinWithAnd, + objectEntries, + PlainObject, +} from '../../utils/utils'; import { createGraphQLError } from '../graphql-errors'; import { FieldContext } from '../query-node-object-type'; import { TypedInputObjectType } from '../typed-input-object-type'; -import { AddChildEntitiesInputField, ReplaceChildEntitiesInputField, UpdateChildEntitiesInputField, UpdateInputField, UpdateInputFieldContext } from './input-fields'; +import { + AddChildEntitiesInputField, + ReplaceChildEntitiesInputField, + UpdateChildEntitiesInputField, + UpdateInputField, + UpdateInputFieldContext, +} from './input-fields'; import { isRelationUpdateField } from './relation-fields'; function getCurrentISODate() { @@ -161,41 +206,90 @@ export class UpdateObjectInputType extends TypedInputObjectTypeitem dictionary first) + // don't use it for very small updates because it adds overhead + const threshold = context.childEntityUpdatesViaDictStrategyThreshold ?? 3; + if (updatedValues.length >= threshold) { + // do deletions first to have a smaller object to update + // (and to preserve backwards-compatibility) + if (removalFilterNode) { + currentNode = new TransformListQueryNode({ + listNode: currentNode, + filterNode: removalFilterNode, + itemVariable: childEntityVarNode, }); - const updateNode = new MergeObjectsQueryNode([ - childEntityVarNode, - new ObjectQueryNode(updates), - ]); - updateMapNode = new ConditionalQueryNode(filterNode, updateNode, updateMapNode); } - } - if (removalFilterNode || updateMapNode) { - currentNode = new TransformListQueryNode({ - listNode: currentNode, - filterNode: removalFilterNode, - innerNode: updateMapNode, - itemVariable: childEntityVarNode, + const dictionaryVar = new VariableQueryNode('dict'); + currentNode = new UpdateChildEntitiesQueryNode({ + originalList: currentNode, + dictionaryVar, + updates: updatedValues.map((value): ChildEntityUpdate => { + const id = (value as any)[ID_FIELD]; + const idNode = new LiteralQueryNode(id); + const childEntityNode = new DynamicPropertyAccessQueryNode( + dictionaryVar, + idNode, + ); + const updatedProperties = updateField.updateInputType.getProperties( + value as PlainObject, + { + ...context, + currentEntityNode: childEntityNode, + }, + ); + const newChildEntityNode = new MergeObjectsQueryNode([ + childEntityNode, + new ObjectQueryNode(updatedProperties), + ]); + return { + idNode, + newChildEntityNode, + }; + }), }); + } else { + let updateMapNode: QueryNode | undefined = undefined; + if (updatedValues.length) { + // build an ugly conditional tree + // looks like this: + // - item + // - item.id == 1 ? update1(item) : item + // - item.id == 2 ? update2(item) : (item.id == 1 ? update1(item) : item) + // ... + // (note the threshold above - we only do this for a very small number of updates) + updateMapNode = childEntityVarNode; + + for (const value of updatedValues) { + const filterNode = new BinaryOperationQueryNode( + childIDQueryNode, + BinaryOperator.EQUAL, + new LiteralQueryNode((value as any)[ID_FIELD]), + ); + const updates = updateField.updateInputType.getProperties( + value as PlainObject, + { + ...context, + currentEntityNode: childEntityVarNode, + }, + ); + const updateNode = new MergeObjectsQueryNode([ + childEntityVarNode, + new ObjectQueryNode(updates), + ]); + updateMapNode = new ConditionalQueryNode(filterNode, updateNode, updateMapNode); + } + } + + if (removalFilterNode || updateMapNode) { + currentNode = new TransformListQueryNode({ + listNode: currentNode, + filterNode: removalFilterNode, + innerNode: updateMapNode, + itemVariable: childEntityVarNode, + }); + } } return [new SetFieldQueryNode(field, currentNode)];