From a4051583ecf36a3e6595864ac5af53568c3bc996 Mon Sep 17 00:00:00 2001 From: Jan Melcher Date: Tue, 7 Nov 2023 16:38:00 +0100 Subject: [PATCH 1/3] feat: add idGenerator to ExecutionOptions to override default behavior This can be useful to e.g. generate predictable IDs for testing purposes. --- core-exports.ts | 2 ++ src/database/arangodb/aql-generator.ts | 8 ++++- src/database/arangodb/arangodb-adapter.ts | 1 + src/database/inmemory/inmemory-adapter.ts | 18 +++++++----- src/database/inmemory/js-generator.ts | 10 +++++-- src/execution/execution-options.ts | 29 +++++++++++++++++++ src/execution/operation-resolver.ts | 3 +- .../create-input-types/input-types.ts | 2 +- .../query-node-object-type/context.ts | 7 ++++- .../query-node-generator.ts | 3 +- 10 files changed, 69 insertions(+), 14 deletions(-) diff --git a/core-exports.ts b/core-exports.ts index e91512f0..7b4c05de 100644 --- a/core-exports.ts +++ b/core-exports.ts @@ -13,6 +13,8 @@ export { ExecutionOptions, MutationMode, ExecutionOptionsCallbackArgs, + Clock, + IDGenerator, } from './src/execution/execution-options'; export { ExecutionResult } from './src/execution/execution-result'; export { diff --git a/src/database/arangodb/aql-generator.ts b/src/database/arangodb/aql-generator.ts index 954d90ce..fd5b8f41 100644 --- a/src/database/arangodb/aql-generator.ts +++ b/src/database/arangodb/aql-generator.ts @@ -87,7 +87,7 @@ import { getCollectionNameForRootEntity, } from './arango-basics'; import { getFlexSearchViewNameForRootEntity } from './schema-migration/arango-search-helpers'; -import { Clock, DefaultClock } from '../../execution/execution-options'; +import { Clock, DefaultClock, IDGenerator, UUIDGenerator } from '../../execution/execution-options'; enum AccessType { /** @@ -112,6 +112,11 @@ export interface QueryGenerationOptions { * An interface to determine the current date/time */ readonly clock: Clock; + + /** + * An interface to generate IDs, e.g. for new child entities. + */ + readonly idGenerator: IDGenerator; } class QueryContext { @@ -1903,6 +1908,7 @@ export function getAQLQuery( undefined, new QueryContext({ clock: options.clock ?? new DefaultClock(), + idGenerator: options.idGenerator ?? new UUIDGenerator(), }), ); } diff --git a/src/database/arangodb/arangodb-adapter.ts b/src/database/arangodb/arangodb-adapter.ts index d719b11f..66d9a025 100644 --- a/src/database/arangodb/arangodb-adapter.ts +++ b/src/database/arangodb/arangodb-adapter.ts @@ -333,6 +333,7 @@ export class ArangoDBAdapter implements DatabaseAdapter { //TODO Execute single statement AQL queries directly without "db.transaction"? aqlQuery = getAQLQuery(queryTree, { clock: options.clock, + idGenerator: options.idGenerator, }); executableQueries = aqlQuery.getExecutableQueries(); } finally { diff --git a/src/database/inmemory/inmemory-adapter.ts b/src/database/inmemory/inmemory-adapter.ts index 67bf9b78..5063b59a 100644 --- a/src/database/inmemory/inmemory-adapter.ts +++ b/src/database/inmemory/inmemory-adapter.ts @@ -15,14 +15,10 @@ import { getCollectionNameForRelation, getCollectionNameForRootEntity } from './ import { JSCompoundQuery, JSExecutableQuery } from './js'; import { getJSQuery } from './js-generator'; import { v4 as uuid } from 'uuid'; -import { DefaultClock } from '../../execution/execution-options'; +import { DefaultClock, IDGenerator, UUIDGenerator } from '../../execution/execution-options'; export class InMemoryDB { collections: { [name: string]: any[] } = {}; - - generateID() { - return uuid(); - } } export class InMemoryAdapter implements DatabaseAdapter { @@ -45,7 +41,10 @@ export class InMemoryAdapter implements DatabaseAdapter { * Gets the javascript source code for a function that executes a transaction * @returns {string} */ - private executeQueries(queries: JSExecutableQuery[]) { + private executeQueries( + queries: JSExecutableQuery[], + { idGenerator }: { idGenerator: IDGenerator }, + ) { const validators = new Map( ALL_QUERY_RESULT_VALIDATOR_FUNCTION_PROVIDERS.map((provider): [string, Function] => [ provider.getValidatorName(), @@ -202,6 +201,8 @@ export class InMemoryAdapter implements DatabaseAdapter { } return [arg]; }, + + generateID: () => idGenerator.generateID({ target: 'root-entity' }), }; let resultHolder: { [p: string]: any } = {}; @@ -266,6 +267,7 @@ export class InMemoryAdapter implements DatabaseAdapter { try { jsQuery = getJSQuery(args.queryTree, { clock: args.clock ?? new DefaultClock(), + idGenerator: args.idGenerator ?? new UUIDGenerator(), }); executableQueries = jsQuery.getExecutableQueries(); } finally { @@ -275,7 +277,9 @@ export class InMemoryAdapter implements DatabaseAdapter { this.logger.trace(`Executing JavaScript: ${jsQuery.toColoredString()}`); } - const data = this.executeQueries(executableQueries); + const data = this.executeQueries(executableQueries, { + idGenerator: args.idGenerator ?? new UUIDGenerator(), + }); return { data, }; diff --git a/src/database/inmemory/js-generator.ts b/src/database/inmemory/js-generator.ts index 38a48bf2..ac7ab926 100644 --- a/src/database/inmemory/js-generator.ts +++ b/src/database/inmemory/js-generator.ts @@ -70,7 +70,7 @@ import { Constructor, decapitalize } from '../../utils/utils'; import { likePatternToRegExp } from '../like-helpers'; import { getCollectionNameForRelation, getCollectionNameForRootEntity } from './inmemory-basics'; import { js, JSCompoundQuery, JSFragment, JSQueryResultVariable, JSVariable } from './js'; -import { Clock, DefaultClock } from '../../execution/execution-options'; +import { Clock, DefaultClock, IDGenerator, UUIDGenerator } from '../../execution/execution-options'; const ID_FIELD_NAME = 'id'; @@ -79,6 +79,11 @@ export interface QueryGenerationOptions { * An interface to determine the current date/time */ readonly clock: Clock; + + /** + * An interface to generate IDs, e.g. for new child entities. + */ + readonly idGenerator: IDGenerator; } class QueryContext { @@ -870,7 +875,7 @@ register(CreateEntityQueryNode, (node, context) => { const idVar = js.variable('id'); return jsExt.executingFunction( js`const ${objVar} = ${processNode(node.objectNode, context)};`, - js`const ${idVar} = db.generateID();`, + js`const ${idVar} = support.generateID();`, js`${objVar}.${js.identifier(ID_FIELD_NAME)} = ${idVar};`, js`${js.collection(getCollectionNameForRootEntity(node.rootEntityType))}.push(${objVar});`, js`return ${idVar};`, @@ -1276,6 +1281,7 @@ export function getJSQuery( undefined, new QueryContext({ clock: options.clock ?? new DefaultClock(), + idGenerator: options.idGenerator ?? new UUIDGenerator(), }), ); } diff --git a/src/execution/execution-options.ts b/src/execution/execution-options.ts index 0761f99d..1594cae9 100644 --- a/src/execution/execution-options.ts +++ b/src/execution/execution-options.ts @@ -1,5 +1,6 @@ import { OperationDefinitionNode } from 'graphql'; import { AuthContext } from '../authorization/auth-basics'; +import { randomUUID } from 'crypto'; export type MutationMode = 'normal' | 'disallowed' | 'rollback'; @@ -90,6 +91,12 @@ export interface ExecutionOptions { * An interface to determine the current date/time. If not specified, system time is used */ readonly clock?: Clock; + + /** + * An interface to generate IDs, e.g. for new child entities. If not specified, random UUIDs + * will be used. + */ + readonly idGenerator?: IDGenerator; } export interface TimeToLiveExecutionOptions { @@ -146,3 +153,25 @@ export class DefaultClock implements Clock { return new Date().toISOString(); } } + +export type IDGenerationTarget = 'root-entity' | 'child-entity'; + +export interface IDGenerationInfo { + readonly target: IDGenerationTarget; +} + +export interface IDGenerator { + /** + * Generate an id that will be used for some entities (e.g. child entities) + */ + generateID(info: IDGenerationInfo): string; +} + +export class UUIDGenerator implements IDGenerator { + /** + * Generates a random UUID + */ + generateID(): string { + return randomUUID(); + } +} diff --git a/src/execution/operation-resolver.ts b/src/execution/operation-resolver.ts index 6d24e6e1..d8d9b722 100644 --- a/src/execution/operation-resolver.ts +++ b/src/execution/operation-resolver.ts @@ -32,7 +32,7 @@ import { } from '../schema-generation/query-node-object-type'; import { SchemaTransformationContext } from '../schema/preparation/transformation-pipeline'; import { getPreciseTime, Watch } from '../utils/watch'; -import { DefaultClock, ExecutionOptions } from './execution-options'; +import { DefaultClock, ExecutionOptions, UUIDGenerator } from './execution-options'; import { ExecutionResult } from './execution-result'; export class OperationResolver { @@ -101,6 +101,7 @@ export class OperationResolver { options.flexSearchMaxFilterableAndSortableAmount, flexSearchRecursionDepth: options.flexSearchRecursionDepth, clock: options.clock ?? new DefaultClock(), + idGenerator: options.idGenerator ?? new UUIDGenerator(), }; queryTree = buildConditionalObjectQueryNode( rootQueryNode, diff --git a/src/schema-generation/create-input-types/input-types.ts b/src/schema-generation/create-input-types/input-types.ts index 3f228c14..d180554f 100644 --- a/src/schema-generation/create-input-types/input-types.ts +++ b/src/schema-generation/create-input-types/input-types.ts @@ -228,7 +228,7 @@ export class CreateChildEntityInputType extends CreateObjectInputType { getAdditionalProperties(value: PlainObject, context: FieldContext) { const now = context.clock.getCurrentTimestamp(); return { - [ID_FIELD]: uuid(), + [ID_FIELD]: context.idGenerator.generateID({ target: 'child-entity' }), [ENTITY_CREATED_AT]: now, [ENTITY_UPDATED_AT]: now, }; diff --git a/src/schema-generation/query-node-object-type/context.ts b/src/schema-generation/query-node-object-type/context.ts index 3f0a0319..08864b67 100644 --- a/src/schema-generation/query-node-object-type/context.ts +++ b/src/schema-generation/query-node-object-type/context.ts @@ -1,5 +1,5 @@ import { FieldSelection } from '../../graphql/query-distiller'; -import { Clock } from '../../execution/execution-options'; +import { Clock, IDGenerator } from '../../execution/execution-options'; /** * A token that corresponds to a FieldSelection but is local to one execution @@ -37,4 +37,9 @@ export interface FieldContext { * An interface to determine the current date/time */ readonly clock: Clock; + + /** + * An interface to generate IDs, e.g. for new child entities + */ + readonly idGenerator: IDGenerator; } diff --git a/src/schema-generation/query-node-object-type/query-node-generator.ts b/src/schema-generation/query-node-object-type/query-node-generator.ts index 3563702e..b6b12880 100644 --- a/src/schema-generation/query-node-object-type/query-node-generator.ts +++ b/src/schema-generation/query-node-object-type/query-node-generator.ts @@ -21,7 +21,7 @@ import { decapitalize, flatMap } from '../../utils/utils'; import { FieldContext, SelectionToken } from './context'; import { QueryNodeField, QueryNodeObjectType } from './definition'; import { extractQueryTreeObjectType, isListTypeIgnoringNonNull } from './utils'; -import { DefaultClock } from '../../execution/execution-options'; +import { DefaultClock, UUIDGenerator } from '../../execution/execution-options'; export function createRootFieldContext( options: Partial< @@ -33,6 +33,7 @@ export function createRootFieldContext( selectionTokenStack: [], selectionToken: new SelectionToken(), clock: options.clock ?? new DefaultClock(), + idGenerator: options.idGenerator ?? new UUIDGenerator(), ...options, }; } From 1ca6cee68c60a727584e60c7346a9c182bb04775 Mon Sep 17 00:00:00 2001 From: Jan Melcher Date: Tue, 7 Nov 2023 17:54:00 +0100 Subject: [PATCH 2/3] test: add tests for child entity updates --- .../tests/update-child-entities.graphql | 117 ++++++ .../tests/update-child-entities.result.json | 342 ++++++++++++++++++ spec/regression/regression-suite.ts | 22 ++ 3 files changed, 481 insertions(+) create mode 100644 spec/regression/logistics/tests/update-child-entities.graphql create mode 100644 spec/regression/logistics/tests/update-child-entities.result.json diff --git a/spec/regression/logistics/tests/update-child-entities.graphql b/spec/regression/logistics/tests/update-child-entities.graphql new file mode 100644 index 00000000..a3960bf8 --- /dev/null +++ b/spec/regression/logistics/tests/update-child-entities.graphql @@ -0,0 +1,117 @@ +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.result.json b/spec/regression/logistics/tests/update-child-entities.result.json new file mode 100644 index 00000000..92e39914 --- /dev/null +++ b/spec/regression/logistics/tests/update-child-entities.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/regression-suite.ts b/spec/regression/regression-suite.ts index 366dd284..a09f8a6c 100644 --- a/spec/regression/regression-suite.ts +++ b/spec/regression/regression-suite.ts @@ -9,6 +9,7 @@ import { ProjectOptions } from '../../src/project/project'; import { loadProjectFromDir } from '../../src/project/project-from-fs'; import { Log4jsLoggerProvider } from '../helpers/log4js-logger-provider'; import { createTempDatabase, initTestData, TestDataEnvironment } from './initialization'; +import { IDGenerationInfo, IDGenerator } from '../../src/execution/execution-options'; import deepEqual = require('deep-equal'); interface TestResult { @@ -45,6 +46,7 @@ export class RegressionSuite { // TODO: implement better regression test architecture for different db types private inMemoryDB: InMemoryDB = new InMemoryDB(); private databaseSpecifier: DatabaseSpecifier; + private readonly idGenerator = new PredictableIDGenerator(); private databaseVersion: string | undefined; constructor(private readonly path: string, private options: RegressionSuiteOptions = {}) { @@ -62,6 +64,7 @@ export class RegressionSuite { : {}; this.inMemoryDB = new InMemoryDB(); + this.idGenerator.resetToPhase('init'); const generalOptions: ProjectOptions = { processError: (e) => { console.error(e.stack); @@ -71,6 +74,7 @@ export class RegressionSuite { authContext: { authRoles: context.authRoles, claims: context.claims }, flexSearchMaxFilterableAndSortableAmount: context.flexSearchMaxFilterableAndSortableAmount, + idGenerator: this.idGenerator, }), modelOptions: { forbiddenRootEntityNames: [], @@ -163,6 +167,7 @@ export class RegressionSuite { if (!this._isSetUpClean) { await this.setUp(); } + this.idGenerator.resetToPhase('test'); if (!this.testDataEnvironment || !this.schema) { throw new Error(`Regression suite not set up correctly`); @@ -267,3 +272,20 @@ export class RegressionSuite { }; } } + +class PredictableIDGenerator implements IDGenerator { + nextNumberPerTarget = new Map(); + phase = 'init'; + + generateID({ target }: IDGenerationInfo): string { + const number = this.nextNumberPerTarget.get(target) ?? 0; + this.nextNumberPerTarget.set(target, number + 1); + return `id_${this.phase}_${String(number).padStart(4, '0')}`; + } + + resetToPhase(phase: string) { + // we have separate phases for init and test because we sometimes skip init + this.phase = phase; + this.nextNumberPerTarget = new Map(); + } +} From 72288515eef6432fa7933dbd593714e9c9121502 Mon Sep 17 00:00:00 2001 From: Jan Melcher Date: Tue, 7 Nov 2023 18:54:52 +0100 Subject: [PATCH 3/3] 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)];