From 9517567262e678b2d24d36f1674291a11a8fb159 Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Fri, 6 Mar 2026 14:50:37 +0100 Subject: [PATCH 01/12] test: verify `usedBy` attaches to correct scope --- .../fixtures/zeebe/used-variables.scopes.bpmn | 73 +++++++++++++++++++ test/spec/zeebe/ZeebeVariableResolver.spec.js | 47 ++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 test/fixtures/zeebe/used-variables.scopes.bpmn diff --git a/test/fixtures/zeebe/used-variables.scopes.bpmn b/test/fixtures/zeebe/used-variables.scopes.bpmn new file mode 100644 index 0000000..e5da4a0 --- /dev/null +++ b/test/fixtures/zeebe/used-variables.scopes.bpmn @@ -0,0 +1,73 @@ + + + + + + + + + + Flow_03u12aa + + + + + + + + + + + + Flow_03u12aa + + + Defines <approved> as local variable + + + + Uses <approved> variable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/zeebe/ZeebeVariableResolver.spec.js b/test/spec/zeebe/ZeebeVariableResolver.spec.js index f6e3536..d2f3ce3 100644 --- a/test/spec/zeebe/ZeebeVariableResolver.spec.js +++ b/test/spec/zeebe/ZeebeVariableResolver.spec.js @@ -31,6 +31,7 @@ import subprocessNoOutputMappingXML from 'test/fixtures/zeebe/sub-process.no-out import longBrokenExpressionXML from 'test/fixtures/zeebe/long-broken-expression.bpmn'; import immediatelyBrokenExpressionXML from 'test/fixtures/zeebe/immediately-broken-expression.bpmn'; import typeResolutionXML from 'test/fixtures/zeebe/type-resolution.bpmn'; +import usedVariablesScopesXML from 'test/fixtures/zeebe/used-variables.scopes.bpmn'; import VariableProvider from 'lib/VariableProvider'; import { getInputOutput } from '../../../lib/base/util/ExtensionElementsUtil'; @@ -2602,6 +2603,52 @@ describe('ZeebeVariableResolver', function() { }); + + describe('used variables - scopes', function() { + + beforeEach(bootstrapModeler(usedVariablesScopesXML, { + additionalModules: [ + ZeebeVariableResolverModule + ], + moddleExtensions: { + zeebe: ZeebeModdle + } + })); + + + it('should attach to local scope', inject(async function(elementRegistry, variableResolver) { + + // given + const subProcess = elementRegistry.get('SubProcess_1'); + + // when + const variables = await variableResolver.getVariablesForElement(subProcess); + + // then + expect(variables).to.variableEqual([ + { name: 'taskResult' }, + { name: 'approved', usedBy: [ 'Task_2' ] } + ]); + })); + + + it('should attach to global scope', inject(async function(elementRegistry, variableResolver) { + + // given + const rootElement = elementRegistry.get('Process_1'); + + // when + const variables = await variableResolver.getVariablesForElement(rootElement); + + // then + expect(variables).to.variableEqual([ + { name: 'taskResult' }, + { name: 'approved', usedBy: [ 'Task_1' ] } + ]); + })); + + }); + }); // helpers ////////////////////// From ace906e5c6a1fd3410de3005a0773901cd64157b Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Fri, 6 Mar 2026 14:39:35 +0100 Subject: [PATCH 02/12] test: validate dual use of read and written variable --- test/fixtures/zeebe/read-write.bpmn | 45 ++++++++++++++ .../zeebe/read-write.hierarchical.bpmn | 45 ++++++++++++++ test/spec/zeebe/ZeebeVariableResolver.spec.js | 62 +++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 test/fixtures/zeebe/read-write.bpmn create mode 100644 test/fixtures/zeebe/read-write.hierarchical.bpmn diff --git a/test/fixtures/zeebe/read-write.bpmn b/test/fixtures/zeebe/read-write.bpmn new file mode 100644 index 0000000..6e7a31b --- /dev/null +++ b/test/fixtures/zeebe/read-write.bpmn @@ -0,0 +1,45 @@ + + + + + + + + + + + + + Write: approved + + + + Read: approved + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/zeebe/read-write.hierarchical.bpmn b/test/fixtures/zeebe/read-write.hierarchical.bpmn new file mode 100644 index 0000000..586a784 --- /dev/null +++ b/test/fixtures/zeebe/read-write.hierarchical.bpmn @@ -0,0 +1,45 @@ + + + + + + + + + + + + + Write: application.approved + + + + Read: application.approved + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/zeebe/ZeebeVariableResolver.spec.js b/test/spec/zeebe/ZeebeVariableResolver.spec.js index d2f3ce3..dd6028f 100644 --- a/test/spec/zeebe/ZeebeVariableResolver.spec.js +++ b/test/spec/zeebe/ZeebeVariableResolver.spec.js @@ -32,6 +32,8 @@ import longBrokenExpressionXML from 'test/fixtures/zeebe/long-broken-expression. import immediatelyBrokenExpressionXML from 'test/fixtures/zeebe/immediately-broken-expression.bpmn'; import typeResolutionXML from 'test/fixtures/zeebe/type-resolution.bpmn'; import usedVariablesScopesXML from 'test/fixtures/zeebe/used-variables.scopes.bpmn'; +import readWriteXML from 'test/fixtures/zeebe/read-write.bpmn'; +import readWriteHierarchicalXML from 'test/fixtures/zeebe/read-write.hierarchical.bpmn'; import VariableProvider from 'lib/VariableProvider'; import { getInputOutput } from '../../../lib/base/util/ExtensionElementsUtil'; @@ -2649,6 +2651,66 @@ describe('ZeebeVariableResolver', function() { }); + + describe('used variables - read and written', function() { + + beforeEach(bootstrapModeler(readWriteXML, { + additionalModules: [ + ZeebeVariableResolverModule + ], + moddleExtensions: { + zeebe: ZeebeModdle + } + })); + + + it('should indicate dual use', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('ValidateApprovedTask'); + + // when + const variables = await variableResolver.getVariablesForElement(task); + + // then + expect(variables).to.variableEqual([ + { name: 'approved', scope: 'Process_1', origin: [ 'ValidateApprovedTask' ], usedBy: [ 'ValidateApprovedTask' ] }, + { name: 'localApproved', scope: 'ValidateApprovedTask' } + ]); + })); + + }); + + + describe('used variables - read and written / hierarchical', function() { + + beforeEach(bootstrapModeler(readWriteHierarchicalXML, { + additionalModules: [ + ZeebeVariableResolverModule + ], + moddleExtensions: { + zeebe: ZeebeModdle + } + })); + + + it('should indicate dual use', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('ValidateApprovedTask'); + + // when + const variables = await variableResolver.getVariablesForElement(task); + + // then + expect(variables).to.variableEqual([ + { name: 'application', scope: 'Process_1', origin: [ 'ValidateApprovedTask' ], usedBy: [ 'ValidateApprovedTask' ] }, + { name: 'localApproved', scope: 'ValidateApprovedTask' } + ]); + })); + + }); + }); // helpers ////////////////////// From 2ee9aea7dad4be1a9cf5c73dcbf1e66139c0de5c Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Fri, 6 Mar 2026 13:52:02 +0100 Subject: [PATCH 03/12] test: verify used variables in pro-code setup --- test/fixtures/zeebe/used-variables.bpmn | 178 ++++++++++++++++++ test/spec/zeebe/ZeebeVariableResolver.spec.js | 83 ++++++++ 2 files changed, 261 insertions(+) create mode 100644 test/fixtures/zeebe/used-variables.bpmn diff --git a/test/fixtures/zeebe/used-variables.bpmn b/test/fixtures/zeebe/used-variables.bpmn new file mode 100644 index 0000000..63face7 --- /dev/null +++ b/test/fixtures/zeebe/used-variables.bpmn @@ -0,0 +1,178 @@ + + + + + + SequenceFlow_2 + + + + SequenceFlow_2 + SequenceFlow_3 + + + SequenceFlow_3 + SequenceFlow_1 + SequenceFlow_4 + + + + =isAgeVerified + + + + SequenceFlow_4 + SequenceFlow_5 + + + SequenceFlow_1 + SequenceFlow_5 + SequenceFlow_6 + + + + + SequenceFlow_6 + SequenceFlow_10 + SequenceFlow_7 + + + =isAgeVerified and is empty(checkResults) + + + + SequenceFlow_10 + SequenceFlow_9 + + + SequenceFlow_9 + + + + SequenceFlow_7 + SequenceFlow_8 + + + SequenceFlow_8 + + + + This process models a typical pro-code scenario where the data model is implicit ("in code") + +* variables are only used, never written in the model +* intelligence should indicate variables used + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/zeebe/ZeebeVariableResolver.spec.js b/test/spec/zeebe/ZeebeVariableResolver.spec.js index dd6028f..2a24175 100644 --- a/test/spec/zeebe/ZeebeVariableResolver.spec.js +++ b/test/spec/zeebe/ZeebeVariableResolver.spec.js @@ -31,6 +31,7 @@ import subprocessNoOutputMappingXML from 'test/fixtures/zeebe/sub-process.no-out import longBrokenExpressionXML from 'test/fixtures/zeebe/long-broken-expression.bpmn'; import immediatelyBrokenExpressionXML from 'test/fixtures/zeebe/immediately-broken-expression.bpmn'; import typeResolutionXML from 'test/fixtures/zeebe/type-resolution.bpmn'; +import usedVariablesXML from 'test/fixtures/zeebe/used-variables.bpmn'; import usedVariablesScopesXML from 'test/fixtures/zeebe/used-variables.scopes.bpmn'; import readWriteXML from 'test/fixtures/zeebe/read-write.bpmn'; import readWriteHierarchicalXML from 'test/fixtures/zeebe/read-write.hierarchical.bpmn'; @@ -2652,6 +2653,88 @@ describe('ZeebeVariableResolver', function() { }); + describe('used variables - pro code', function() { + + beforeEach(bootstrapModeler(usedVariablesXML, { + additionalModules: [ + ZeebeVariableResolverModule + ], + moddleExtensions: { + zeebe: ZeebeModdle + } + })); + + + it('should expose used variables globally', inject(async function(elementRegistry, variableResolver) { + + // when + const allVariables = await variableResolver.getVariables(); + + // then + expect(allVariables).to.have.property('Process_1'); + + expect(allVariables['Process_1']).to.variableEqual([ + { name: 'isAgeVerified', usedBy: [ 'SequenceFlow_1', 'SequenceFlow_10' ], scope: undefined, origin: undefined }, + { name: 'subscriptionRequestID', usedBy: [ 'VerifyAgeTask' ], scope: undefined, origin: undefined }, + { name: 'checkResults', usedBy: [ 'SequenceFlow_10' ], scope: undefined, origin: undefined } + ]); + + // and when + const rootElement = elementRegistry.get('Process_1'); + + const processVariables = await variableResolver.getProcessVariables(rootElement); + + expect(processVariables).to.eql(allVariables['Process_1']); + })); + + + describe('should expose used variables per element', function() { + + it('sequence flow', inject(async function(elementRegistry, variableResolver) { + + // when + const flow = elementRegistry.get('SequenceFlow_1'); + + const variables = await variableResolver.getVariablesForElement(flow); + + // then + expect(variables).to.variableEqual([ + { name: 'isAgeVerified', usedBy: [ 'SequenceFlow_1', 'SequenceFlow_10' ], scope: undefined, origin: undefined } + ]); + })); + + + it('receive task', inject(async function(elementRegistry, variableResolver) { + + // when + const task = elementRegistry.get('VerifyAgeTask'); + + const variables = await variableResolver.getVariablesForElement(task); + + // then + expect(variables).to.variableEqual([ + { name: 'subscriptionRequestID', usedBy: [ 'VerifyAgeTask' ], scope: undefined, origin: undefined } + ]); + })); + + + it('process', inject(async function(elementRegistry, variableResolver) { + + // when + const rootElement = elementRegistry.get('Process_1'); + + const variables = await variableResolver.getVariablesForElement(rootElement); + + // then + // TODO(nikku): should that include variables not used by process? + expect(variables).to.variableEqual([]); + })); + + }); + + }); + + describe('used variables - read and written', function() { beforeEach(bootstrapModeler(readWriteXML, { From 7439d0eb375b07f62a7145ee9dfe9b56170d7a2c Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Sat, 7 Mar 2026 09:33:34 +0100 Subject: [PATCH 04/12] test: verify filtering of variables works --- test/fixtures/zeebe/filtering.bpmn | 82 ++++++++++ test/spec/zeebe/ZeebeVariableResolver.spec.js | 142 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 test/fixtures/zeebe/filtering.bpmn diff --git a/test/fixtures/zeebe/filtering.bpmn b/test/fixtures/zeebe/filtering.bpmn new file mode 100644 index 0000000..3660321 --- /dev/null +++ b/test/fixtures/zeebe/filtering.bpmn @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + declare: subFoo = globalFoo + + + + declare: taskFoo = subFoo +read: taskFoo + + + + write: localResult (-> depending on taskFoo) + + + + produce: taskResult = localResult + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/zeebe/ZeebeVariableResolver.spec.js b/test/spec/zeebe/ZeebeVariableResolver.spec.js index 2a24175..b5b7de4 100644 --- a/test/spec/zeebe/ZeebeVariableResolver.spec.js +++ b/test/spec/zeebe/ZeebeVariableResolver.spec.js @@ -35,6 +35,7 @@ import usedVariablesXML from 'test/fixtures/zeebe/used-variables.bpmn'; import usedVariablesScopesXML from 'test/fixtures/zeebe/used-variables.scopes.bpmn'; import readWriteXML from 'test/fixtures/zeebe/read-write.bpmn'; import readWriteHierarchicalXML from 'test/fixtures/zeebe/read-write.hierarchical.bpmn'; +import filteringXML from 'test/fixtures/zeebe/filtering.bpmn'; import VariableProvider from 'lib/VariableProvider'; import { getInputOutput } from '../../../lib/base/util/ExtensionElementsUtil'; @@ -2794,6 +2795,147 @@ describe('ZeebeVariableResolver', function() { }); + + describe('filtering', function() { + + beforeEach(bootstrapModeler(filteringXML, { + additionalModules: [ + ZeebeVariableResolverModule + ], + moddleExtensions: { + zeebe: ZeebeModdle + } + })); + + it('should filter read of global variables', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('SubProcess_1'); + + // when + const variables = await variableResolver.getVariablesForElement(task, { written:false, local:false }); + + // then + expect(variables).to.variableEqual([ + { name: 'globalFoo', scope: undefined, usedBy: [ 'SubProcess_1' ] } + ]); + })); + + it('should NOT filter', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('Task_1'); + + // when + const variables = await variableResolver.getVariablesForElement(task); + + // then + expect(variables).to.variableEqual([ + { name: 'subFoo', scope: 'SubProcess_1', origin: [ 'SubProcess_1' ], usedBy: [ 'Task_1' ] }, + { name: 'taskFoo', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] }, + { name: 'localResult', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] }, + { name: 'taskResult', scope: 'Process_1', origin: [ 'Task_1' ] } + ]); + })); + + + it('should filter read', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('Task_1'); + + // when + const variables = await variableResolver.getVariablesForElement(task, { written: false }); + + // then + expect(variables).to.variableEqual([ + { name: 'subFoo', scope: 'SubProcess_1', origin: [ 'SubProcess_1' ], usedBy: [ 'Task_1' ] }, + { name: 'taskFoo', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] }, + { name: 'localResult', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] } + ]); + })); + + + it('should filter read / local', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('Task_1'); + + // when + const variables = await variableResolver.getVariablesForElement(task, { written: false, external: false }); + + // then + expect(variables).to.variableEqual([ + { name: 'taskFoo', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] }, + { name: 'localResult', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] } + ]); + })); + + + it('should filter read / global', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('Task_1'); + + // when + const variables = await variableResolver.getVariablesForElement(task, { written: false, local: false }); + + // then + expect(variables).to.variableEqual([ + { name: 'subFoo', scope: 'SubProcess_1', origin: [ 'SubProcess_1' ], usedBy: [ 'Task_1' ] } + ]); + })); + + + it('should filter write', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('Task_1'); + + // when + const variables = await variableResolver.getVariablesForElement(task, { read: false }); + + // then + expect(variables).to.variableEqual([ + { name: 'taskFoo', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] }, + { name: 'localResult', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] }, + { name: 'taskResult', scope: 'Process_1', origin: [ 'Task_1' ] } + ]); + })); + + + it('should filter written / local', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('Task_1'); + + // when + const variables = await variableResolver.getVariablesForElement(task, { read: false, external: false }); + + // then + expect(variables).to.variableEqual([ + { name: 'taskFoo', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] }, + { name: 'localResult', scope: 'Task_1', origin: [ 'Task_1' ], usedBy: [ 'Task_1' ] } + ]); + })); + + + it('should filter written / global', inject(async function(elementRegistry, variableResolver) { + + // given + const task = elementRegistry.get('Task_1'); + + // when + const variables = await variableResolver.getVariablesForElement(task, { read: false, local: false }); + + // then + expect(variables).to.variableEqual([ + { name: 'taskResult', scope: 'Process_1', origin: [ 'Task_1' ] } + ]); + })); + + }); + }); // helpers ////////////////////// From d6957826f24f03bbd67ed217b8436eac4b30985c Mon Sep 17 00:00:00 2001 From: Nico Rehwaldt Date: Sat, 7 Mar 2026 09:01:55 +0100 Subject: [PATCH 05/12] docs: update to represent variable use --- README.md | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 1403fc8..d464ad5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ [![CI](https://github.com/bpmn-io/variable-resolver/actions/workflows/CI.yml/badge.svg)](https://github.com/bpmn-io/variable-resolver/actions/workflows/CI.yml) -A bpmn-js extension to add and manage additional variable extractors in bpmn diagrams. +An extension for [bpmn-js](https://github.com/bpmn-io/bpmn-js) that makes the data model of the diagram available to other components. + +> [!NOTE] +> As of version `v3` this library exposes both written and consumed variables. ## Usage @@ -30,17 +33,29 @@ const modeler = new BpmnModeler({ ### Retrieving variables -To retrieve the variables from a diagram, use one of the following methods of the `variableResolver` service: +Retrieve the variables from a diagram using the `variableResolver` service: ```javascript -const elementRegistry = modeler.get('elementRegistry'); const variableResolver = modeler.get('variableResolver'); +const elementRegistry = modeler.get('elementRegistry'); +// retrieve variables relevant to an element const task = elementRegistry.get('Task_1'); -const process = elementRegistry.get('Process_1'); -await variableResolver.getVariablesForElement(task); // returns variables in the scope of the element -await variableResolver.getProcessVariables(process); // returns all variables for the process, not filtering by scope +// variables available in scope of +await variableResolver.getVariablesForElement(task); + +// variables read by , excluding local ones +await variableResolver.getVariablesForElement(task, { read: true, local: false }); + +// all variables written by +await variableResolver.getVariablesForElement(task, { written: true }); + +// retrieve all variables defined in a process +const processElement = elementRegistry.get('Process_1'); + +// returns all variables for the process (unfiltered), for local processing +await variableResolver.getProcessVariables(processElement); ``` ### Adding a variable extractor @@ -76,11 +91,9 @@ export const MyExtension = { ### Advanced use-cases -By default, `getVariablesForElement` and `getProcessVariables` will attempt to merge variables with the same name and scope into -one. Entry structure and types are then mixed. +By default, `getVariablesForElement` and `getProcessVariables` merge variables with the same name and scope into one - entry structure and types are mixed in the merged representation. -In some cases, you might want access to the raw data, e.g. to run lint rules to detect potential schema mismatches between providers. -For this, you can use +In some cases, you might want access to the raw data, e.g. to run lint rules to detect potential schema mismatches between providers. For this, you can use `getRawVariables`: ```javascript const variableResolver = modeler.get('variableResolver'); From a04270bfa3e0722b3ce5eacffc9fd7ec12c1c548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Steinr=C3=BCcken?= Date: Tue, 10 Mar 2026 10:54:07 +0100 Subject: [PATCH 06/12] feat: provide variables used in feel expressions --- lib/base/VariableResolver.js | 60 +- lib/zeebe/VariableResolver.js | 4 +- lib/zeebe/util/feelUtility.js | 305 +++++++- package-lock.json | 26 +- package.json | 2 + .../zeebe/mappings/input-output-conflict.bpmn | 30 + .../zeebe/mappings/input-requirements.bpmn | 51 ++ .../zeebe/mappings/script-task-inputs.bpmn | 32 + .../script-task-with-input-mappings.bpmn | 76 ++ test/spec/zeebe/Mappings.spec.js | 712 ++++++++++++++++++ 10 files changed, 1258 insertions(+), 40 deletions(-) create mode 100644 test/fixtures/zeebe/mappings/input-output-conflict.bpmn create mode 100644 test/fixtures/zeebe/mappings/input-requirements.bpmn create mode 100644 test/fixtures/zeebe/mappings/script-task-inputs.bpmn create mode 100644 test/fixtures/zeebe/mappings/script-task-with-input-mappings.bpmn diff --git a/lib/base/VariableResolver.js b/lib/base/VariableResolver.js index 9bd36e4..f857252 100644 --- a/lib/base/VariableResolver.js +++ b/lib/base/VariableResolver.js @@ -24,13 +24,14 @@ import { getParents } from './util/elementsUtil'; * Base Class that handles additional variable extractors, variable parsing and caching. */ export class BaseVariableResolver { + constructor(eventBus, bpmnjs) { this.providers = []; this._eventBus = eventBus; this._bpmnjs = bpmnjs; this.rawVariables = new CachedValue(this._generateRawVariables.bind(this)); - this.parsedVariables = new CachedValue(async () => { + this._allParsedVariables = new CachedValue(async () => { const rawVariables = await this.getRawVariables(); const context = { variables: rawVariables }; @@ -39,6 +40,17 @@ export class BaseVariableResolver { return context.variables; }); + this.parsedVariables = new CachedValue(async () => { + const allParsed = await this._allParsedVariables.get(); + + // Filter out variables with no scope (e.g. consumed variable markers) + const filtered = {}; + for (const key in allParsed) { + filtered[key] = allParsed[key].filter(v => v.scope); + } + + return filtered; + }); eventBus.on([ 'commandStack.changed', 'diagram.clear', 'import.done', 'variables.changed' ], () => { this.invalidateCache(); @@ -100,6 +112,7 @@ export class BaseVariableResolver { */ invalidateCache() { this.rawVariables.invalidate(); + this._allParsedVariables.invalidate(); this.parsedVariables.invalidate(); } @@ -159,12 +172,26 @@ export class BaseVariableResolver { variables.forEach(variable => { const existingVariable = mergedVariables.find(v => v.name === variable.name && v.scope === variable.scope + && (v.scope || !v.usedBy) && (variable.scope || !variable.usedBy) ); if (existingVariable) { merge('origin', existingVariable, variable); merge('provider', existingVariable, variable); mergeEntries(existingVariable, variable); + + // Preserve usedBy from either side during merge + if (variable.usedBy) { + if (!existingVariable.usedBy) { + existingVariable.usedBy = [ ...variable.usedBy ]; + } else { + variable.usedBy.forEach(target => { + if (!existingVariable.usedBy.includes(target)) { + existingVariable.usedBy.push(target); + } + }); + } + } } else { mergedVariables.push(variable); } @@ -251,7 +278,7 @@ export class BaseVariableResolver { * * @async * @param {ModdleElement} element - * @returns {Array} variables + * @returns {Promise>} variables */ async getVariablesForElement(element) { const bo = getBusinessObject(element); @@ -261,14 +288,14 @@ export class BaseVariableResolver { // (1) get variables for given scope var scopeVariables = allVariables.filter(function(variable) { - return variable.scope.id === bo.id; + return variable.scope && variable.scope.id === bo.id; }); // (2) get variables for parent scopes var parents = getParents(bo); var parentsScopeVariables = allVariables.filter(function(variable) { - return parents.find(function(parent) { + return variable.scope && parents.find(function(parent) { return parent.id === variable.scope.id; }); }); @@ -299,6 +326,31 @@ export class BaseVariableResolver { throw new Error('not implemented VariableResolver#_getScope'); } + /** + * Returns consumed variables for an element — variables + * the element needs as input for its expressions and mappings. + * + * Uses `getVariables()` instead of `getVariablesForElement()` to + * bypass the name-based deduplication that would drop requirement + * entries for variables that also exist in ancestor scopes. + * + * @param {Object} element + * @returns {Promise>} + */ + async getConsumedVariablesForElement(element) { + const allVariablesByRoot = await this._allParsedVariables.get() + .catch(() => { + return {}; + }); + + const allVariables = Object.values(allVariablesByRoot).flat(); + + return allVariables.filter(v => + v.usedBy && v.usedBy.length > 0 + && !v.scope + && v.origin.length === 1 && v.origin[0].id === element.id + ); + } } BaseVariableResolver.$inject = [ 'eventBus', 'bpmnjs' ]; diff --git a/lib/zeebe/VariableResolver.js b/lib/zeebe/VariableResolver.js index ca7a413..621a0b9 100644 --- a/lib/zeebe/VariableResolver.js +++ b/lib/zeebe/VariableResolver.js @@ -100,9 +100,9 @@ export default class ZeebeVariableResolver extends BaseVariableResolver { for (const key in rawVariables) { const variables = rawVariables[key]; - const newVariables = parseVariables(variables); + const { resolvedVariables, consumedVariables } = parseVariables(variables); - mappedVariables[key] = [ ...variables, ...newVariables ]; + mappedVariables[key] = [ ...variables, ...resolvedVariables, ...consumedVariables ]; } context.variables = mappedVariables; diff --git a/lib/zeebe/util/feelUtility.js b/lib/zeebe/util/feelUtility.js index 3a4392f..a840837 100644 --- a/lib/zeebe/util/feelUtility.js +++ b/lib/zeebe/util/feelUtility.js @@ -4,37 +4,112 @@ import { } from '@lezer/lr'; import { has, isNil } from 'min-dash'; +import { FeelAnalyzer } from '@bpmn-io/feel-analyzer'; import { EntriesContext } from './VariableContext'; import { getExtensionElementsList } from '../../base/util/ExtensionElementsUtil'; import { is } from 'bpmn-js/lib/util/ModelUtil'; import { getParents } from '../../base/util/elementsUtil'; import { mergeList } from '../../base/util/listUtil'; +import { mergeEntries } from '../../base/VariableResolver'; +import { camundaBuiltins, camundaReservedNameBuiltins } from '@camunda/feel-builtins'; -export function parseVariables(variables) { +const feelAnalyzer = new FeelAnalyzer({ + dialect: 'expression', + parserDialect: 'camunda', + builtins: camundaBuiltins, + reservedNameBuiltins: camundaReservedNameBuiltins +}); +/** + * Parse FEEL-based variable expressions and return resolved + consumed variables. + * + * This includes script expressions and IO mappings, and is designed to handle + * FEEL-enabled variable definitions exposed by the collected variable origins. + * Resolution uses the highest-priority expression for each variable/origin pair. + * Consumption analysis inspects all relevant non-output expressions. + * + * @param {Array} variables + * @returns {{ resolvedVariables: Array, consumedVariables: Array }} + */ +export function parseVariables(variables) { const variablesToResolve = []; + const analysisResults = []; // Step 1 - Parse all variables and populate all that don't have references // to other variables variables.forEach(variable => { variable.origin.forEach(origin => { - const expressionDetails = getExpressionDetails(variable, origin); + const expressionCandidates = findExpressions(variable, origin); - if (!expressionDetails) { + if (expressionCandidates.length === 0) { return; } - const { expression, unresolved } = expressionDetails; + const expression = selectPrimaryExpression(expressionCandidates); + + if (isNil(expression)) { + return; + } + + const expressionDetails = getExpressionDetails(expression); + + const { + unresolved + } = expressionDetails; variablesToResolve.push({ variable, expression, unresolved }); + + const requirementAnalyses = collectRequirementAnalyses(expressionCandidates, expression, expressionDetails); + + for (const { expressionType, inputs } of requirementAnalyses) { + analysisResults.push({ + origin, + scope: origin, + targetName: variable.name, + inputs, + expressionType + }); + } }); }); // Step 2 - Order all Variables and resolve them - return resolveReferences(variablesToResolve, variables); + const resolvedVariables = resolveReferences(variablesToResolve, variables); + + // Step 3 - Build consumed variables from collected analyses + const { consumed: consumedVariables, localUsages } = buildConsumedVariables(analysisResults); + + // Step 4 - Annotate locally-provided variables with usedBy information + for (const { variableName, targetName, origin } of localUsages) { + const variable = variables.find(v => + v.name === variableName && v.scope === origin + ); + + if (variable) { + if (!variable.usedBy) { + variable.usedBy = []; + } + + if (!variable.usedBy.includes(targetName)) { + variable.usedBy.push(targetName); + } + } + } + + return { resolvedVariables, consumedVariables }; } +/** + * Resolve variable expressions against each other in dependency order. + * + * Variables with expression mappings are evaluated in a best-effort topological + * order. Variables without mappings are provided as initial context. + * + * @param {Array<{ variable: ProcessVariable, expression: string, unresolved: Array }>} variablesToResolve + * @param {Array} allVariables + * @returns {Array} + */ function resolveReferences(variablesToResolve, allVariables) { const sortedVariables = []; @@ -73,7 +148,7 @@ function resolveReferences(variablesToResolve, allVariables) { const rootContext = { name: 'OuterContext', - entries: toOptimizedFormat(variablesWithoutMappings) + entries: toOptimizedFormat(variablesWithoutMappings.filter(v => v.scope)) }; const newVariables = []; @@ -205,56 +280,143 @@ export function getResultContext(expression, variables = {}) { } /** - * Given a Variable and a specific origin, return the mapping expression and all - * unresolved variables used in that expression. Returns undefined if no mapping - * exists for the given origin. + * Find all matching expression sources for a variable on a given origin element. + * + * Returns candidates ordered by resolution priority (first = highest). + * Local variables: script > input-mapping (script result overwrites at runtime). + * Global variables: output-mapping > script. * * @param {ProcessVariable} variable * @param {djs.model.Base} origin - * @returns {{ expression: string, unresolved: Array }}} + * @returns {Array<{ type: string, value: string }>} */ -function getExpressionDetails(variable, origin) { +function findExpressions(variable, origin) { + const isLocal = variable.scope === origin; + + const candidates = isLocal + ? [ + { type: 'script', value: getScriptExpression(variable, origin) }, + { type: 'input-mapping', value: getIoInputExpression(variable, origin) }, + ] + : [ + { type: 'output-mapping', value: getIoOutputExpression(variable, origin) }, + { type: 'script', value: getScriptExpression(variable, origin) }, + ]; + + return candidates.filter(c => !isNil(c.value)); +} - // if variable scope is !== origin (global), prioritize IoExpression over ScriptExpression - // if variable scope is === origin (local), prioritize ScriptExpression over IoExpression - const expression = variable.scope !== origin - ? getIoExpression(variable, origin) ?? getScriptExpression(variable, origin) - : getScriptExpression(variable, origin) ?? getIoExpression(variable, origin); +/** + * Pick the highest-priority expression from expression candidates. + * + * @param {Array<{ type: string, value: string }>} expressionCandidates + * @returns {string|undefined} + */ +function selectPrimaryExpression(expressionCandidates) { + const [ primary ] = expressionCandidates; - if (isNil(expression)) { - return; + return primary && primary.value; +} + +/** + * Analyze candidate expressions for consumed variables. + * + * Output mappings are ignored because they do not consume variables in the + * local execution scope. + * + * @param {Array<{ type: string, value: string }>} expressionCandidates + * @param {string} primaryExpression + * @param {{ unresolved: Array, inputs: Array }} primaryExpressionDetails + * @returns {Array<{ expressionType: string, inputs: Array }>} + */ +function collectRequirementAnalyses(expressionCandidates, primaryExpression, primaryExpressionDetails) { + const requirementAnalyses = []; + + for (const { type, value } of expressionCandidates) { + if (type === 'output-mapping') { + continue; + } + + const analysis = value === primaryExpression + ? primaryExpressionDetails + : getExpressionDetails(value); + + if (analysis.inputs.length > 0) { + requirementAnalyses.push({ + expressionType: type, + inputs: analysis.inputs + }); + } } - const result = getResultContext(expression); + return requirementAnalyses; +} + +/** + * Given a FEEL expression, return unresolved variables and consumed input variables. + * + * @param {string} expression + * @returns {{ unresolved: Array, inputs: Array }} + */ +function getExpressionDetails(expression) { + const result = getResultContext(expression); const unresolved = findUnresolvedVariables(result); - return { expression, unresolved }; + // Static values (no leading '=') are string literals, not FEEL expressions. + // They don't reference any variables and must not be analyzed for inputs. + if (!expression.startsWith('=')) { + return { unresolved, inputs: [] }; + } + + const analysisResult = feelAnalyzer.analyzeExpression(expression); + const inputs = analysisResult.valid !== false && analysisResult.inputs + ? analysisResult.inputs + : []; + + return { unresolved, inputs }; } /** - * Given a Variable and a specific origin, return the expression. + * Given a variable and origin, return input mapping expression targeting the variable. * - * Returns `undefined` if no mapping exists for the given origin. + * @param {ProcessVariable} variable + * @param {djs.model.Base} origin + * @returns {string|undefined} + */ +function getIoInputExpression(variable, origin) { + return getIoExpressionByType(variable, origin, 'input'); +} + +/** + * Given a variable and origin, return output mapping expression targeting the variable. * * @param {ProcessVariable} variable * @param {djs.model.Base} origin + * @returns {string|undefined} + */ +function getIoOutputExpression(variable, origin) { + return getIoExpressionByType(variable, origin, 'output'); +} + +/** + * Given a variable and origin, return mapping expression by mapping type. * + * @param {ProcessVariable} variable + * @param {djs.model.Base} origin + * @param {'input'|'output'} mappingType * @returns {string|undefined} */ -function getIoExpression(variable, origin) { +function getIoExpressionByType(variable, origin, mappingType) { const ioMapping = getExtensionElementsList(origin, 'zeebe:IoMapping')[0]; if (!ioMapping) { return; } - let mappings; - if (origin === variable.scope) { - mappings = ioMapping.inputParameters; - } else { - mappings = ioMapping.outputParameters; - } + const mappings = mappingType === 'input' + ? ioMapping.inputParameters + : ioMapping.outputParameters; if (!mappings) { return; @@ -274,7 +436,7 @@ function getIoExpression(variable, origin) { * * @param {ProcessVariable} variable * @param {djs.model.Base} origin - * @returns {string} + * @returns {string|undefined} */ function getScriptExpression(variable, origin) { const script = getExtensionElementsList(origin, 'zeebe:Script')[0]; @@ -443,7 +605,7 @@ function filterForScope(context, variable) { for (const key in context.entries) { const entry = context.entries[key]; - if (validScopes.find(scope => scope.id === entry.scope.id)) { + if (entry.scope && validScopes.find(scope => scope.id === entry.scope.id)) { scopedResults.entries[key] = entry; } } @@ -494,3 +656,84 @@ export function getElementNamesToRemove(moddleElement, inputOutput) { return namesToFilter; } + +/** + * Build consumed variables from pre-collected analysis results. + * + * @param {Array<{ origin: object, targetName: string, inputs: Array, expressionType: string }>} analysisResults + * @returns {Array} consumed variables + */ +function buildConsumedVariables(analysisResults) { + const consumedVariables = {}; + const localUsages = []; + const inputMappingTargetsCache = {}; + + for (const { origin, targetName, inputs, expressionType } of analysisResults) { + + if (!inputMappingTargetsCache[origin.id]) { + inputMappingTargetsCache[origin.id] = getInputMappingTargetNames(origin); + } + const orderedTargets = inputMappingTargetsCache[origin.id]; + + // Input mappings are order-sensitive: only earlier targets are available. + // Scripts can reference all input mapping targets. + let availableLocalTargets; + if (expressionType === 'input-mapping') { + const targetIndex = orderedTargets.indexOf(targetName); + availableLocalTargets = new Set(orderedTargets.slice(0, targetIndex)); + } else { + availableLocalTargets = new Set(orderedTargets); + } + + for (const inputVar of inputs) { + + // Track locally-provided variables that are used by output expressions + if (availableLocalTargets.has(inputVar.name)) { + localUsages.push({ + variableName: inputVar.name, + origin, + targetName + }); + continue; + } + + const key = `${inputVar.name}__${origin.id}`; + + if (!consumedVariables[key]) { + consumedVariables[key] = { + name: inputVar.name, + origin: [ origin ], + entries: inputVar.entries || [], + usedBy: [ targetName ], + provider: [], + }; + } else { + if (!consumedVariables[key].usedBy.includes(targetName)) { + consumedVariables[key].usedBy.push(targetName); + } + + // Merge entries from the new input into the existing requirement + // e.g. `a.b` and `a.c` should result in `a: {b, c}` + if (inputVar.entries && inputVar.entries.length) { + mergeEntries(consumedVariables[key], { entries: inputVar.entries }); + } + } + } + } + + return { consumed: Object.values(consumedVariables), localUsages }; +} + +/** + * Get ordered input mapping target names for an element. + * + * @param {djs.model.Base} origin + * @returns {Array} + */ +function getInputMappingTargetNames(origin) { + const ioMapping = getExtensionElementsList(origin, 'zeebe:IoMapping')[0]; + if (!ioMapping || !ioMapping.inputParameters) return []; + return ioMapping.inputParameters.map(p => p.target); +} + + diff --git a/package-lock.json b/package-lock.json index 8ac35e0..9dc8c8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "MIT", "dependencies": { "@bpmn-io/extract-process-variables": "^2.2.1", + "@bpmn-io/feel-analyzer": "^0.1.0", "@bpmn-io/lezer-feel": "^2.3.0", + "@camunda/feel-builtins": "^1.0.0", "@lezer/common": "^1.5.1", "min-dash": "^5.0.0" }, @@ -421,6 +423,18 @@ "min-dash": "^5.0.0" } }, + "node_modules/@bpmn-io/feel-analyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@bpmn-io/feel-analyzer/-/feel-analyzer-0.1.0.tgz", + "integrity": "sha512-lQp3EZY6WbBI0y0PwnlXIk+fJO9yZpotFbd2a1UaIrule9i+wk9au99J+Md1RjT38GiqcBzk2mMftRkRb8Iezg==", + "license": "MIT", + "dependencies": { + "@bpmn-io/lezer-feel": "^2.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@bpmn-io/feel-editor": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@bpmn-io/feel-editor/-/feel-editor-2.5.0.tgz", @@ -546,7 +560,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@camunda/feel-builtins/-/feel-builtins-1.0.0.tgz", "integrity": "sha512-/0w86UVmNcNqlZB9n/4Ro5wqHt+JuBKwD8kt+O7ASxSqs80D1y4BMfgu+We+O7cA8MWbt9eNKUjwRgGf4f75TQ==", - "dev": true, "license": "MIT" }, "node_modules/@camunda/zeebe-element-templates-json-schema": { @@ -8944,6 +8957,14 @@ "min-dash": "^5.0.0" } }, + "@bpmn-io/feel-analyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@bpmn-io/feel-analyzer/-/feel-analyzer-0.1.0.tgz", + "integrity": "sha512-lQp3EZY6WbBI0y0PwnlXIk+fJO9yZpotFbd2a1UaIrule9i+wk9au99J+Md1RjT38GiqcBzk2mMftRkRb8Iezg==", + "requires": { + "@bpmn-io/lezer-feel": "^2.1.0" + } + }, "@bpmn-io/feel-editor": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@bpmn-io/feel-editor/-/feel-editor-2.5.0.tgz", @@ -9042,8 +9063,7 @@ "@camunda/feel-builtins": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@camunda/feel-builtins/-/feel-builtins-1.0.0.tgz", - "integrity": "sha512-/0w86UVmNcNqlZB9n/4Ro5wqHt+JuBKwD8kt+O7ASxSqs80D1y4BMfgu+We+O7cA8MWbt9eNKUjwRgGf4f75TQ==", - "dev": true + "integrity": "sha512-/0w86UVmNcNqlZB9n/4Ro5wqHt+JuBKwD8kt+O7ASxSqs80D1y4BMfgu+We+O7cA8MWbt9eNKUjwRgGf4f75TQ==" }, "@camunda/zeebe-element-templates-json-schema": { "version": "0.36.0", diff --git a/package.json b/package.json index fc20347..2564eeb 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "license": "MIT", "dependencies": { "@bpmn-io/extract-process-variables": "^2.2.1", + "@bpmn-io/feel-analyzer": "^0.1.0", "@bpmn-io/lezer-feel": "^2.3.0", + "@camunda/feel-builtins": "^1.0.0", "@lezer/common": "^1.5.1", "min-dash": "^5.0.0" }, diff --git a/test/fixtures/zeebe/mappings/input-output-conflict.bpmn b/test/fixtures/zeebe/mappings/input-output-conflict.bpmn new file mode 100644 index 0000000..a56569f --- /dev/null +++ b/test/fixtures/zeebe/mappings/input-output-conflict.bpmn @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/zeebe/mappings/input-requirements.bpmn b/test/fixtures/zeebe/mappings/input-requirements.bpmn new file mode 100644 index 0000000..4090136 --- /dev/null +++ b/test/fixtures/zeebe/mappings/input-requirements.bpmn @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/zeebe/mappings/script-task-inputs.bpmn b/test/fixtures/zeebe/mappings/script-task-inputs.bpmn new file mode 100644 index 0000000..143b66f --- /dev/null +++ b/test/fixtures/zeebe/mappings/script-task-inputs.bpmn @@ -0,0 +1,32 @@ + + + + + + + + Flow_1 + + + + + + + Flow_1 + + + + + + + + + + + + + + + + + diff --git a/test/fixtures/zeebe/mappings/script-task-with-input-mappings.bpmn b/test/fixtures/zeebe/mappings/script-task-with-input-mappings.bpmn new file mode 100644 index 0000000..5bc798d --- /dev/null +++ b/test/fixtures/zeebe/mappings/script-task-with-input-mappings.bpmn @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/zeebe/Mappings.spec.js b/test/spec/zeebe/Mappings.spec.js index 1c4a6e8..a258aaf 100644 --- a/test/spec/zeebe/Mappings.spec.js +++ b/test/spec/zeebe/Mappings.spec.js @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import sinon from 'sinon'; import TestContainer from 'mocha-test-container-support'; @@ -13,6 +14,7 @@ import { ZeebeVariableResolverModule } from 'lib/'; import chainedMappingsXML from 'test/fixtures/zeebe/mappings/chained-mappings.bpmn'; import chainedMappingsAnyXML from 'test/fixtures/zeebe/mappings/chained-mappings.any.bpmn'; +import consumedVariablesXML from 'test/fixtures/zeebe/mappings/input-requirements.bpmn'; import primitivesXML from 'test/fixtures/zeebe/mappings/primitives.bpmn'; import mergingXML from 'test/fixtures/zeebe/mappings/merging.bpmn'; import mergingChildrenXML from 'test/fixtures/zeebe/mappings/merging.children.bpmn'; @@ -24,6 +26,10 @@ import propagationXML from 'test/fixtures/zeebe/mappings/propagation.bpmn'; import scriptTaskXML from 'test/fixtures/zeebe/mappings/script-task.bpmn'; import scriptTaskEmptyExpressionXML from 'test/fixtures/zeebe/mappings/script-task-empty-expression.bpmn'; import scriptTaskOutputNoNameXML from 'test/fixtures/zeebe/mappings/script-task-output-no-name.bpmn'; +import scriptTaskInputsXML from 'test/fixtures/zeebe/mappings/script-task-inputs.bpmn'; +import scriptTaskWithInputMappingsXML from 'test/fixtures/zeebe/mappings/script-task-with-input-mappings.bpmn'; +import inputOutputConflictXML from 'test/fixtures/zeebe/mappings/input-output-conflict.bpmn'; +import emptyXML from 'test/fixtures/zeebe/empty.bpmn'; import VariableProvider from 'lib/VariableProvider'; @@ -881,6 +887,712 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { }); }); + + describe('Consumed Variables', function() { + + beforeEach(bootstrap(consumedVariablesXML)); + + + it('should extract input variables from simple expression', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('SimpleTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + const a = variables.find(v => v.name === 'a'); + const b = variables.find(v => v.name === 'b'); + expect(a).to.exist; + expect(b).to.exist; + expect(a.usedBy).to.eql([ 'sum' ]); + expect(b.usedBy).to.eql([ 'sum' ]); + })); + + + it('should extract input variables with nested properties', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('NestedTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(variables).to.variableInclude({ + name: 'order', + entries: [ { name: 'items' } ], + usedBy: [ 'orderItems' ] + }); + })); + + + it('should deduplicate input variables across mappings', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('MultiInputTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then - y is used in both input mappings but should only appear once + const yVars = variables.filter(v => v.name === 'y'); + expect(yVars).to.have.length(1); + })); + + + it('should track multiple usedBy targets', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('MultiInputTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then - y is used in both result1 (=x+y) and result2 (=y+z) + const y = variables.find(v => v.name === 'y'); + expect(y.usedBy).to.eql([ 'result1', 'result2' ]); + })); + + + it('should extract all unique input variables from multiple mappings', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('MultiInputTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(variables).to.variableInclude([ + { name: 'x' }, + { name: 'y' }, + { name: 'z' } + ]); + })); + + + it('should merge entries from multiple expressions referencing the same variable', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('MergedEntriesTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then - a.b and a.c should result in a: { entries: [b, c] } + expect(variables).to.variableInclude({ + name: 'a', + entries: [ + { name: 'b' }, + { name: 'c' } + ] + }); + })); + + + it('should scope consumed variables to the origin task', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('SimpleTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then - a originates from SimpleTask (the task with the input mapping) + const a = variables.find(v => v.name === 'a'); + expect(a).to.exist; + expect(a.scope).to.not.exist; + })); + + + it('should keep provider and extracted variables separate', inject(async function(variableResolver, elementRegistry) { + + // given + const root = elementRegistry.get('Process_1'); + const task = elementRegistry.get('SimpleTask'); + + createProvider({ + variables: [ { name: 'a', type: 'Number', scope: root } ], + variableResolver + }); + + // when - getVariables should have the scoped provider variable + const variables = (await variableResolver.getVariables())['Process_1']; + const scopedA = variables.find(v => v.name === 'a' && v.scope); + expect(scopedA).to.exist; + + // and getConsumedVariablesForElement should have the unscoped consumed variable + const consumed = await variableResolver.getConsumedVariablesForElement(task); + const unscopedA = consumed.find(v => v.name === 'a' && !v.scope); + expect(unscopedA).to.exist; + })); + + }); + + + describe('Consumed Variables - Input/Output Conflict', function() { + + beforeEach(bootstrap(inputOutputConflictXML)); + + + it('should extract consumed variable from input mapping when another task has output mapping with same name', inject(async function(variableResolver, elementRegistry) { + + // given + const inputTask = elementRegistry.get('InputTask'); + const outputTask = elementRegistry.get('OutputTask'); + + // when + const inputReqs = await variableResolver.getConsumedVariablesForElement(inputTask); + const outputReqs = await variableResolver.getConsumedVariablesForElement(outputTask); + + // then - foo is used in the input mapping of both tasks + expect(inputReqs).to.variableInclude({ name: 'foo' }); + expect(outputReqs).to.variableInclude({ name: 'foo' }); + })); + + + it('should return foo as consumed variable for InputTask via getConsumedVariablesForElement', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('InputTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(requirements).to.variableInclude({ name: 'foo' }); + })); + + + it('should return foo as consumed variable for OutputTask via getConsumedVariablesForElement', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('OutputTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(requirements).to.variableInclude({ name: 'foo' }); + })); + + }); + + + describe('Consumed Variables - Script Tasks', function() { + + beforeEach(bootstrap(scriptTaskInputsXML)); + + + it('should extract input variables from script task expression', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('firstTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + const a = variables.find(v => v.name === 'a'); + const b = variables.find(v => v.name === 'b'); + expect(a).to.exist; + expect(b).to.exist; + expect(a.usedBy).to.eql([ 'firstResult' ]); + expect(b.usedBy).to.eql([ 'firstResult' ]); + })); + + + it('should extract input variables from second script task', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('secondTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + const d = variables.find(v => v.name === 'd'); + const f = variables.find(v => v.name === 'f'); + expect(d).to.exist; + expect(f).to.exist; + expect(d.usedBy).to.eql([ 'secondResult' ]); + expect(f.usedBy).to.eql([ 'secondResult' ]); + })); + + + it('should associate script task inputs with the task origin', inject(async function(variableResolver, elementRegistry) { + + // given + const firstTask = elementRegistry.get('firstTask'); + const secondTask = elementRegistry.get('secondTask'); + + // when + const firstVars = await variableResolver.getConsumedVariablesForElement(firstTask); + const secondVars = await variableResolver.getConsumedVariablesForElement(secondTask); + + // then - a originates from firstTask, d originates from secondTask + expect(firstVars).to.variableInclude({ name: 'a', origin: [ 'firstTask' ] }); + expect(secondVars).to.variableInclude({ name: 'd', origin: [ 'secondTask' ] }); + })); + + + it('should not include consumed variables in getVariablesForElement', inject(async function(variableResolver, elementRegistry) { + + // given + const firstTask = elementRegistry.get('firstTask'); + + // when + const variables = await variableResolver.getVariablesForElement(firstTask); + + // then - consumed variables (a, b, d, f) should not appear since they have no scope + const names = variables.map(v => v.name); + expect(names).to.not.include('a'); + expect(names).to.not.include('b'); + expect(names).to.not.include('d'); + expect(names).to.not.include('f'); + })); + + }); + + + describe('Consumed Variables - Script Task with Input Mappings', function() { + + beforeEach(bootstrap(scriptTaskWithInputMappingsXML)); + + + it('should extract consumed variables from input mapping expressions but not from script for locally mapped variables', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('scriptWithInputs'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(variables).to.variableInclude([ + { name: 'processVar1' }, + { name: 'processVar2' } + ]); + + const names = variables.map(v => v.name); + expect(names).to.not.include('localA'); + expect(names).to.not.include('localB'); + })); + + + it('should still extract consumed variables from script without input mappings', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('scriptWithoutInputs'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(variables).to.variableInclude([ + { name: 'x' }, + { name: 'y' } + ]); + })); + + + it('should not require locally provided variable when chained input mapping references earlier target', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('chainedInputMappings'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(variables).to.variableInclude({ name: 'processVar3' }); + + const names = variables.map(v => v.name); + expect(names).to.not.include('localC'); + expect(names).to.not.include('localD'); + })); + + + it('should keep shadowed variable as consumed variable when mapping a to a', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('shadowingTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(variables).to.variableInclude({ name: 'a' }); + })); + + + it('should handle shadowing with chaining correctly', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('shadowingChainedTask'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(variables).to.variableInclude({ name: 'a' }); + + const names = variables.map(v => v.name); + expect(names).to.not.include('b'); + })); + + + it('should not require second input mapping variable used in script', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('scriptUsesSecondInput'); + + // when + const variables = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(variables).to.variableEqual([ + { name: 'def' } + ]); + })); + + + it('should annotate locally-provided input mapping variables with usedBy', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('scriptWithInputs'); + + // when + const variables = await variableResolver.getVariablesForElement(task); + + // then - localA and localB should have usedBy pointing to scriptResult + expect(variables).to.variableInclude([ + { name: 'localA' }, + { name: 'localB' } + ]); + + const localA = variables.find(v => v.name === 'localA'); + const localB = variables.find(v => v.name === 'localB'); + expect(localA.usedBy).to.eql([ 'scriptResult' ]); + expect(localB.usedBy).to.eql([ 'scriptResult' ]); + })); + + + it('should annotate chained input mapping variables with usedBy', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('chainedInputMappings'); + + // when + const variables = await variableResolver.getVariablesForElement(task); + + // then - localC is used by both localD and chainedResult + const localC = variables.find(v => v.name === 'localC'); + const localD = variables.find(v => v.name === 'localD'); + expect(localC).to.exist; + expect(localC.usedBy).to.include('localD'); + expect(localC.usedBy).to.include('chainedResult'); + expect(localD).to.exist; + expect(localD.usedBy).to.eql([ 'chainedResult' ]); + })); + + + it('should not add usedBy for variables without input mappings', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('scriptWithoutInputs'); + + // when + const variables = await variableResolver.getVariablesForElement(task); + + // then - scriptResult2 should not have usedBy (it has no input mappings) + const scriptResult2 = variables.find(v => v.name === 'scriptResult2'); + expect(scriptResult2).to.exist; + expect(scriptResult2.usedBy).to.not.exist; + })); + + }); + + + describe('#getConsumedVariablesForElement', function() { + + describe('with input mappings', function() { + + beforeEach(bootstrap(consumedVariablesXML)); + + + it('should return consumed variables for a simple task', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('SimpleTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then - both 'a' and 'b' are consumed variables for SimpleTask + expect(requirements).to.variableEqual([ + { name: 'a' }, + { name: 'b' } + ]); + })); + + + it('should return requirements with nested properties', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('NestedTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(requirements).to.variableEqual([ + { name: 'order', entries: [ { name: 'items' } ] } + ]); + })); + + + it('should return requirements from multiple input mappings', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('MultiInputTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(requirements).to.variableInclude([ + { name: 'x' }, + { name: 'y' }, + { name: 'z' } + ]); + })); + + + it('should return empty array for element without consumed variables', inject(async function(variableResolver, elementRegistry) { + + // given + const process = elementRegistry.get('Process_1'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(process); + + // then + expect(requirements).to.be.an('array').that.is.empty; + })); + + + it('should not return variables from other elements', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('SimpleTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then - should not include variables from MultiInputTask or NestedTask + expect(requirements).to.variableEqual([ + { name: 'a' }, + { name: 'b' } + ]); + })); + + + it('should include usedBy information', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('SimpleTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then + const b = requirements.find(v => v.name === 'b'); + expect(b).to.exist; + expect(b.usedBy).to.eql([ 'sum' ]); + })); + + + it('should return consumed variables even when other tasks use the same variable', inject(async function(variableResolver, elementRegistry) { + + // given - MergedEntriesTask uses 'a' which is also used by SimpleTask + // Consumed variables are per-task and should not be merged + const task = elementRegistry.get('MergedEntriesTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(requirements).to.variableEqual([ + { name: 'a', entries: [ { name: 'b' }, { name: 'c' } ] } + ]); + })); + + }); + + + describe('with script tasks', function() { + + beforeEach(bootstrap(scriptTaskInputsXML)); + + + it('should return consumed variables for script task', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('firstTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(requirements).to.variableInclude([ + { name: 'a' }, + { name: 'b' } + ]); + })); + + + it('should only return requirements for the requested task', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('secondTask'); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(task); + + // then + expect(requirements).to.variableInclude([ + { name: 'd' }, + { name: 'f' } + ]); + + const names = requirements.map(v => v.name); + expect(names).to.not.include('a'); + expect(names).to.not.include('b'); + })); + + }); + + + describe('with script tasks and input mappings', function() { + + beforeEach(bootstrap(scriptTaskWithInputMappingsXML)); + + + it('should only return process variables as consumed variables, not locally mapped ones', inject(async function(variableResolver, elementRegistry) { + + // when + const requirements = await variableResolver.getConsumedVariablesForElement( + elementRegistry.get('scriptWithInputs') + ); + + // then + expect(requirements).to.variableEqual([ + { name: 'processVar1' }, + { name: 'processVar2' } + ]); + })); + + + it('should return all script variables for task without input mappings', inject(async function(variableResolver, elementRegistry) { + + // when + const requirements = await variableResolver.getConsumedVariablesForElement( + elementRegistry.get('scriptWithoutInputs') + ); + + // then + expect(requirements).to.variableEqual([ + { name: 'x' }, + { name: 'y' } + ]); + })); + + + it('should handle chained input mappings respecting order', inject(async function(variableResolver, elementRegistry) { + + // when + const requirements = await variableResolver.getConsumedVariablesForElement( + elementRegistry.get('chainedInputMappings') + ); + + // then + expect(requirements).to.variableEqual([ + { name: 'processVar3' } + ]); + })); + + + it('should keep shadowed variable as requirement when mapping a to a', inject(async function(variableResolver, elementRegistry) { + + // when + const requirements = await variableResolver.getConsumedVariablesForElement( + elementRegistry.get('shadowingTask') + ); + + // then + expect(requirements).to.variableEqual([ + { name: 'a' } + ]); + })); + + + it('should handle shadowing with chained mappings', inject(async function(variableResolver, elementRegistry) { + + // when + const requirements = await variableResolver.getConsumedVariablesForElement( + elementRegistry.get('shadowingChainedTask') + ); + + // then + expect(requirements).to.variableEqual([ + { name: 'a' } + ]); + })); + + + it('should allow script to use all input targets regardless of order', inject(async function(variableResolver, elementRegistry) { + + // when + const requirements = await variableResolver.getConsumedVariablesForElement( + elementRegistry.get('scriptUsesSecondInput') + ); + + // then + expect(requirements).to.variableEqual([ + { name: 'def' } + ]); + })); + + }); + + + describe('error handling', function() { + + beforeEach(bootstrap(emptyXML)); + + + it('should return empty array when getVariables fails', inject(async function(variableResolver, elementRegistry) { + + // given + const process = elementRegistry.get('Process_1'); + sinon.stub(variableResolver, 'getVariables').rejects(new Error('test error')); + + // when + const requirements = await variableResolver.getConsumedVariablesForElement(process); + + // then + expect(requirements).to.be.an('array').that.is.empty; + + variableResolver.getVariables.restore(); + })); + + }); + + }); + }); // helpers ////////////////////// From 1bb60117a970f1b945a3ec97d7a6593af7cb8175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Steinr=C3=BCcken?= Date: Tue, 10 Mar 2026 10:54:31 +0100 Subject: [PATCH 07/12] chore: add performance test --- test/fixtures/zeebe/mappings/feel-100.bpmn | 2009 ++++++++++++++++++++ test/spec/zeebe/Performance.spec.js | 43 + 2 files changed, 2052 insertions(+) create mode 100644 test/fixtures/zeebe/mappings/feel-100.bpmn create mode 100644 test/spec/zeebe/Performance.spec.js diff --git a/test/fixtures/zeebe/mappings/feel-100.bpmn b/test/fixtures/zeebe/mappings/feel-100.bpmn new file mode 100644 index 0000000..ae1c3d1 --- /dev/null +++ b/test/fixtures/zeebe/mappings/feel-100.bpmn @@ -0,0 +1,2009 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/spec/zeebe/Performance.spec.js b/test/spec/zeebe/Performance.spec.js new file mode 100644 index 0000000..89a7fc2 --- /dev/null +++ b/test/spec/zeebe/Performance.spec.js @@ -0,0 +1,43 @@ +import { expect } from 'chai'; + +import ZeebeModdle from 'zeebe-bpmn-moddle/resources/zeebe'; + +import { bootstrapModeler, getBpmnJS } from 'test/TestHelper'; + +import { ZeebeVariableResolverModule } from 'lib/'; + +import feel100 from 'test/fixtures/zeebe/mappings/feel-100.bpmn'; + +const ITERATIONS = 5; + +describe('ZeebeVariableResolver - Performance', function() { + + const bootstrap = bootstrapModeler(feel100, { + additionalModules: [ + ZeebeVariableResolverModule + ], + moddleExtensions: { + zeebe: ZeebeModdle + } + }); + + it('performance should not decrease significantly', async function() { + + let totalDuration = 0; + + for (let index = 0; index < ITERATIONS; index++) { + const start = performance.now(); + await bootstrap.call(this); + const variableResolver = getBpmnJS().get('variableResolver'); + await variableResolver.getVariables(); + const end = performance.now(); + const duration = end - start; + + totalDuration += duration; + } + + const averageDuration = totalDuration / ITERATIONS; + expect(averageDuration).to.be.lessThan(300); + }); + +}); From 9bfb4cc84728bd1395dfec70669cfe14ab3e0333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Steinr=C3=BCcken?= Date: Tue, 10 Mar 2026 10:54:56 +0100 Subject: [PATCH 08/12] fix!: include unscoped variables in getVariables --- lib/base/VariableResolver.js | 23 ++-------- lib/zeebe/util/feelUtility.js | 5 +-- test/spec/zeebe/Mappings.spec.js | 77 ++++++-------------------------- 3 files changed, 18 insertions(+), 87 deletions(-) diff --git a/lib/base/VariableResolver.js b/lib/base/VariableResolver.js index f857252..86730e1 100644 --- a/lib/base/VariableResolver.js +++ b/lib/base/VariableResolver.js @@ -16,7 +16,7 @@ import { getParents } from './util/elementsUtil'; /** * @typedef {AdditionalVariable} ProcessVariable * @property {Array} origin - * @property {ModdleElement} scope + * @property {ModdleElement} [scope] * @property {Array} provider */ @@ -24,14 +24,13 @@ import { getParents } from './util/elementsUtil'; * Base Class that handles additional variable extractors, variable parsing and caching. */ export class BaseVariableResolver { - constructor(eventBus, bpmnjs) { this.providers = []; this._eventBus = eventBus; this._bpmnjs = bpmnjs; this.rawVariables = new CachedValue(this._generateRawVariables.bind(this)); - this._allParsedVariables = new CachedValue(async () => { + this.parsedVariables = new CachedValue(async () => { const rawVariables = await this.getRawVariables(); const context = { variables: rawVariables }; @@ -40,17 +39,6 @@ export class BaseVariableResolver { return context.variables; }); - this.parsedVariables = new CachedValue(async () => { - const allParsed = await this._allParsedVariables.get(); - - // Filter out variables with no scope (e.g. consumed variable markers) - const filtered = {}; - for (const key in allParsed) { - filtered[key] = allParsed[key].filter(v => v.scope); - } - - return filtered; - }); eventBus.on([ 'commandStack.changed', 'diagram.clear', 'import.done', 'variables.changed' ], () => { this.invalidateCache(); @@ -112,7 +100,6 @@ export class BaseVariableResolver { */ invalidateCache() { this.rawVariables.invalidate(); - this._allParsedVariables.invalidate(); this.parsedVariables.invalidate(); } @@ -338,11 +325,7 @@ export class BaseVariableResolver { * @returns {Promise>} */ async getConsumedVariablesForElement(element) { - const allVariablesByRoot = await this._allParsedVariables.get() - .catch(() => { - return {}; - }); - + const allVariablesByRoot = await this.parsedVariables.get(); const allVariables = Object.values(allVariablesByRoot).flat(); return allVariables.filter(v => diff --git a/lib/zeebe/util/feelUtility.js b/lib/zeebe/util/feelUtility.js index a840837..11bb7d2 100644 --- a/lib/zeebe/util/feelUtility.js +++ b/lib/zeebe/util/feelUtility.js @@ -702,10 +702,9 @@ function buildConsumedVariables(analysisResults) { if (!consumedVariables[key]) { consumedVariables[key] = { name: inputVar.name, - origin: [ origin ], + origin: undefined, entries: inputVar.entries || [], - usedBy: [ targetName ], - provider: [], + usedBy: [ origin ] }; } else { if (!consumedVariables[key].usedBy.includes(targetName)) { diff --git a/test/spec/zeebe/Mappings.spec.js b/test/spec/zeebe/Mappings.spec.js index a258aaf..de24ef7 100644 --- a/test/spec/zeebe/Mappings.spec.js +++ b/test/spec/zeebe/Mappings.spec.js @@ -902,12 +902,10 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const variables = await variableResolver.getConsumedVariablesForElement(task); // then - const a = variables.find(v => v.name === 'a'); - const b = variables.find(v => v.name === 'b'); - expect(a).to.exist; - expect(b).to.exist; - expect(a.usedBy).to.eql([ 'sum' ]); - expect(b.usedBy).to.eql([ 'sum' ]); + expect(variables).to.variableEqual([ + { name: 'a', usedBy: [ 'SimpleTask' ] }, + { name: 'b', usedBy: [ 'SimpleTask' ] } + ]); })); @@ -923,7 +921,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { expect(variables).to.variableInclude({ name: 'order', entries: [ { name: 'items' } ], - usedBy: [ 'orderItems' ] + usedBy: [ 'NestedTask' ] }); })); @@ -942,20 +940,6 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { })); - it('should track multiple usedBy targets', inject(async function(variableResolver, elementRegistry) { - - // given - const task = elementRegistry.get('MultiInputTask'); - - // when - const variables = await variableResolver.getConsumedVariablesForElement(task); - - // then - y is used in both result1 (=x+y) and result2 (=y+z) - const y = variables.find(v => v.name === 'y'); - expect(y.usedBy).to.eql([ 'result1', 'result2' ]); - })); - - it('should extract all unique input variables from multiple mappings', inject(async function(variableResolver, elementRegistry) { // given @@ -1095,12 +1079,10 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const variables = await variableResolver.getConsumedVariablesForElement(task); // then - const a = variables.find(v => v.name === 'a'); - const b = variables.find(v => v.name === 'b'); - expect(a).to.exist; - expect(b).to.exist; - expect(a.usedBy).to.eql([ 'firstResult' ]); - expect(b.usedBy).to.eql([ 'firstResult' ]); + expect(variables).to.variableEqual([ + { name: 'a', usedBy: [ 'firstTask' ] }, + { name: 'b', usedBy: [ 'firstTask' ] } + ]); })); @@ -1113,28 +1095,10 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const variables = await variableResolver.getConsumedVariablesForElement(task); // then - const d = variables.find(v => v.name === 'd'); - const f = variables.find(v => v.name === 'f'); - expect(d).to.exist; - expect(f).to.exist; - expect(d.usedBy).to.eql([ 'secondResult' ]); - expect(f.usedBy).to.eql([ 'secondResult' ]); - })); - - - it('should associate script task inputs with the task origin', inject(async function(variableResolver, elementRegistry) { - - // given - const firstTask = elementRegistry.get('firstTask'); - const secondTask = elementRegistry.get('secondTask'); - - // when - const firstVars = await variableResolver.getConsumedVariablesForElement(firstTask); - const secondVars = await variableResolver.getConsumedVariablesForElement(secondTask); - - // then - a originates from firstTask, d originates from secondTask - expect(firstVars).to.variableInclude({ name: 'a', origin: [ 'firstTask' ] }); - expect(secondVars).to.variableInclude({ name: 'd', origin: [ 'secondTask' ] }); + expect(variables).to.variableEqual([ + { name: 'd', usedBy: [ 'secondTask' ] }, + { name: 'f', usedBy: [ 'secondTask' ] } + ]); })); @@ -1400,21 +1364,6 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { })); - it('should include usedBy information', inject(async function(variableResolver, elementRegistry) { - - // given - const task = elementRegistry.get('SimpleTask'); - - // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); - - // then - const b = requirements.find(v => v.name === 'b'); - expect(b).to.exist; - expect(b.usedBy).to.eql([ 'sum' ]); - })); - - it('should return consumed variables even when other tasks use the same variable', inject(async function(variableResolver, elementRegistry) { // given - MergedEntriesTask uses 'a' which is also used by SimpleTask From 6236a315caa0a5ed5e1e138961ace9af74bb4034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Steinr=C3=BCcken?= Date: Tue, 10 Mar 2026 11:05:33 +0100 Subject: [PATCH 09/12] adjust for https://github.com/bpmn-io/variable-resolver/pull/90 (used variables - scopes) note: internal usedby gets merged, thats why i added both tasks --- lib/base/VariableResolver.js | 51 ++++++- lib/zeebe/util/feelUtility.js | 127 ++++++++++++++---- test/spec/zeebe/ZeebeVariableResolver.spec.js | 4 +- 3 files changed, 151 insertions(+), 31 deletions(-) diff --git a/lib/base/VariableResolver.js b/lib/base/VariableResolver.js index 86730e1..2ba94fa 100644 --- a/lib/base/VariableResolver.js +++ b/lib/base/VariableResolver.js @@ -287,14 +287,26 @@ export class BaseVariableResolver { }); }); - const reversedVariables = [ ...scopeVariables, ...parentsScopeVariables ].reverse(); + // (3) include descendant-scoped variables that are used outside their + // own scope but still within the current scope (cross-scope leak) + const leakedVariables = allVariables.filter(variable => { + return variable.scope + && variable.scope.id !== bo.id + && isElementInScope(variable.scope, bo) + && isUsedInScope(variable, bo) + && isUsedOutsideOwnScope(variable); + }); + + const reversedVariables = [ ...leakedVariables, ...scopeVariables, ...parentsScopeVariables ].reverse(); const seenNames = new Set(); return reversedVariables.filter(variable => { + const provider = variable.provider || []; + // if external variable, keep - if (variable.provider.find(extractor => extractor !== this._baseExtractor)) { + if (provider.find(extractor => extractor !== this._baseExtractor)) { return true; } @@ -329,9 +341,8 @@ export class BaseVariableResolver { const allVariables = Object.values(allVariablesByRoot).flat(); return allVariables.filter(v => - v.usedBy && v.usedBy.length > 0 - && !v.scope - && v.origin.length === 1 && v.origin[0].id === element.id + !v.scope + && v.usedBy && v.usedBy.some((a) => a.id === element.id) ); } } @@ -431,4 +442,34 @@ function cloneVariable(variable) { } return newVariable; +} + +function isUsedInScope(variable, scopeElement) { + if (!variable.usedBy || !Array.isArray(variable.usedBy)) { + return false; + } + + return variable.usedBy.some(usedBy => isElementInScope(usedBy, scopeElement)); +} + +function isElementInScope(element, scopeElement) { + if (!element || !element.id || !scopeElement || !scopeElement.id) { + return false; + } + + if (element.id === scopeElement.id) { + return true; + } + + return getParents(element).some(parent => parent.id === scopeElement.id); +} + +function isUsedOutsideOwnScope(variable) { + if (!variable.scope || !Array.isArray(variable.usedBy)) { + return false; + } + + return variable.usedBy.some(usedBy => { + return usedBy && usedBy.id && !isElementInScope(usedBy, variable.scope); + }); } \ No newline at end of file diff --git a/lib/zeebe/util/feelUtility.js b/lib/zeebe/util/feelUtility.js index 11bb7d2..6a8608d 100644 --- a/lib/zeebe/util/feelUtility.js +++ b/lib/zeebe/util/feelUtility.js @@ -82,22 +82,83 @@ export function parseVariables(variables) { // Step 4 - Annotate locally-provided variables with usedBy information for (const { variableName, targetName, origin } of localUsages) { - const variable = variables.find(v => - v.name === variableName && v.scope === origin - ); + const variable = findNearestScopedVariable(variables, variableName, origin); - if (variable) { - if (!variable.usedBy) { - variable.usedBy = []; + if (!variable) { + continue; + } + + // Keep existing behavior for same-origin mappings (`usedBy: [ 'targetVar' ]`) + // and use the consuming element for ancestor-scoped variables. + const usage = variable.scope === origin ? targetName : origin; + + if (!variable.usedBy) { + variable.usedBy = []; + } + + if (!hasUsage(variable.usedBy, usage)) { + variable.usedBy.push(usage); + } + } + + // Step 5 - Bridge consumed usages back to uniquely scoped declarations + annotateConsumedUsagesToScopedVariables(variables, consumedVariables); + + return { resolvedVariables, consumedVariables }; +} + +function annotateConsumedUsagesToScopedVariables(variables, consumedVariables) { + for (const consumedVariable of consumedVariables) { + const scopedCandidates = variables.filter(v => v.name === consumedVariable.name && v.scope); + + if (scopedCandidates.length !== 1) { + continue; + } + + const scopedVariable = scopedCandidates[0]; + + if (!scopedVariable.usedBy) { + scopedVariable.usedBy = []; + } + + for (const usage of consumedVariable.usedBy || []) { + if (!usage || !usage.id) { + continue; } - if (!variable.usedBy.includes(targetName)) { - variable.usedBy.push(targetName); + if (!hasUsage(scopedVariable.usedBy, usage)) { + scopedVariable.usedBy.push(usage); } } } +} - return { resolvedVariables, consumedVariables }; +function findNearestScopedVariable(variables, variableName, origin) { + const scopes = [ origin, ...getParents(origin) ]; + + for (const scope of scopes) { + const variable = variables.find(v => v.name === variableName && v.scope === scope); + + if (variable) { + return variable; + } + } + + return null; +} + +function hasUsage(usages, usage) { + return usages.some(existing => { + if (existing === usage) { + return true; + } + + if (typeof existing === 'string' && typeof usage === 'string') { + return existing === usage; + } + + return existing && usage && existing.id && usage.id && existing.id === usage.id; + }); } /** @@ -669,21 +730,12 @@ function buildConsumedVariables(analysisResults) { const inputMappingTargetsCache = {}; for (const { origin, targetName, inputs, expressionType } of analysisResults) { - - if (!inputMappingTargetsCache[origin.id]) { - inputMappingTargetsCache[origin.id] = getInputMappingTargetNames(origin); - } - const orderedTargets = inputMappingTargetsCache[origin.id]; - - // Input mappings are order-sensitive: only earlier targets are available. - // Scripts can reference all input mapping targets. - let availableLocalTargets; - if (expressionType === 'input-mapping') { - const targetIndex = orderedTargets.indexOf(targetName); - availableLocalTargets = new Set(orderedTargets.slice(0, targetIndex)); - } else { - availableLocalTargets = new Set(orderedTargets); - } + const availableLocalTargets = getAvailableLocalTargets( + origin, + expressionType, + targetName, + inputMappingTargetsCache + ); for (const inputVar of inputs) { @@ -735,4 +787,31 @@ function getInputMappingTargetNames(origin) { return ioMapping.inputParameters.map(p => p.target); } +function getAvailableLocalTargets(origin, expressionType, targetName, inputMappingTargetsCache) { + const availableTargets = new Set(); + const scopes = [ origin, ...getParents(origin) ]; + + for (const scope of scopes) { + if (!inputMappingTargetsCache[scope.id]) { + inputMappingTargetsCache[scope.id] = getInputMappingTargetNames(scope); + } + + const orderedTargets = inputMappingTargetsCache[scope.id]; + + // Input mappings on the current element are order-sensitive; ancestor + // mappings are already established and fully available. + if (scope === origin && expressionType === 'input-mapping') { + const targetIndex = orderedTargets.indexOf(targetName); + const availableOwnTargets = targetIndex === -1 ? orderedTargets : orderedTargets.slice(0, targetIndex); + + availableOwnTargets.forEach(target => availableTargets.add(target)); + continue; + } + + orderedTargets.forEach(target => availableTargets.add(target)); + } + + return availableTargets; +} + diff --git a/test/spec/zeebe/ZeebeVariableResolver.spec.js b/test/spec/zeebe/ZeebeVariableResolver.spec.js index b5b7de4..328d972 100644 --- a/test/spec/zeebe/ZeebeVariableResolver.spec.js +++ b/test/spec/zeebe/ZeebeVariableResolver.spec.js @@ -2631,7 +2631,7 @@ describe('ZeebeVariableResolver', function() { // then expect(variables).to.variableEqual([ { name: 'taskResult' }, - { name: 'approved', usedBy: [ 'Task_2' ] } + { name: 'approved', usedBy: [ 'Task_1', 'Task_2' ] } ]); })); @@ -2647,7 +2647,7 @@ describe('ZeebeVariableResolver', function() { // then expect(variables).to.variableEqual([ { name: 'taskResult' }, - { name: 'approved', usedBy: [ 'Task_1' ] } + { name: 'approved', usedBy: [ 'Task_1', 'Task_2' ] } ]); })); From 602e9e4b801b26456c4cf32f1dce3ab3146728b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Steinr=C3=BCcken?= Date: Tue, 10 Mar 2026 11:10:12 +0100 Subject: [PATCH 10/12] adjust for https://github.com/bpmn-io/variable-resolver/pull/91 --- lib/zeebe/VariableResolver.js | 1 + lib/zeebe/util/feelUtility.js | 62 +++++++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/lib/zeebe/VariableResolver.js b/lib/zeebe/VariableResolver.js index 621a0b9..9e2d7fd 100644 --- a/lib/zeebe/VariableResolver.js +++ b/lib/zeebe/VariableResolver.js @@ -146,6 +146,7 @@ function expandHierarchicalNames(variables) { scope, provider, origin, + usedBy: lastVariable.usedBy ? [ ...lastVariable.usedBy ] : undefined, entries: [ lastVariable ] diff --git a/lib/zeebe/util/feelUtility.js b/lib/zeebe/util/feelUtility.js index 6a8608d..707486c 100644 --- a/lib/zeebe/util/feelUtility.js +++ b/lib/zeebe/util/feelUtility.js @@ -109,28 +109,64 @@ export function parseVariables(variables) { function annotateConsumedUsagesToScopedVariables(variables, consumedVariables) { for (const consumedVariable of consumedVariables) { - const scopedCandidates = variables.filter(v => v.name === consumedVariable.name && v.scope); + const candidateNames = getConsumedVariableCandidateNames(consumedVariable); - if (scopedCandidates.length !== 1) { - continue; + for (const candidateName of candidateNames) { + annotateConsumedUsagesToScopedVariableByName(variables, candidateName, consumedVariable.usedBy || []); } + } +} + +function annotateConsumedUsagesToScopedVariableByName(variables, variableName, usages) { + const scopedCandidates = variables.filter(v => v.name === variableName && v.scope); + + if (scopedCandidates.length !== 1) { + return; + } + + const scopedVariable = scopedCandidates[0]; - const scopedVariable = scopedCandidates[0]; + if (!scopedVariable.usedBy) { + scopedVariable.usedBy = []; + } - if (!scopedVariable.usedBy) { - scopedVariable.usedBy = []; + for (const usage of usages) { + if (!usage || !usage.id) { + continue; } - for (const usage of consumedVariable.usedBy || []) { - if (!usage || !usage.id) { - continue; - } + if (!hasUsage(scopedVariable.usedBy, usage)) { + scopedVariable.usedBy.push(usage); + } + } +} - if (!hasUsage(scopedVariable.usedBy, usage)) { - scopedVariable.usedBy.push(usage); - } +function getConsumedVariableCandidateNames(consumedVariable) { + const names = [ consumedVariable.name ]; + + if (!consumedVariable.entries || !consumedVariable.entries.length) { + return names; + } + + return [ + ...names, + ...getNestedConsumedVariableNames(consumedVariable.name, consumedVariable.entries) + ]; +} + +function getNestedConsumedVariableNames(prefix, entries) { + const names = []; + + for (const entry of entries || []) { + const name = `${prefix}.${entry.name}`; + names.push(name); + + if (entry.entries && entry.entries.length) { + names.push(...getNestedConsumedVariableNames(name, entry.entries)); } } + + return names; } function findNearestScopedVariable(variables, variableName, origin) { From da8f22c911057851d5cb1e6161babd0c6a4b4d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Steinr=C3=BCcken?= Date: Tue, 10 Mar 2026 11:14:04 +0100 Subject: [PATCH 11/12] adjust for https://github.com/bpmn-io/variable-resolver/pull/92 --- lib/base/VariableResolver.js | 57 +++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/lib/base/VariableResolver.js b/lib/base/VariableResolver.js index 2ba94fa..ed1468b 100644 --- a/lib/base/VariableResolver.js +++ b/lib/base/VariableResolver.js @@ -301,7 +301,7 @@ export class BaseVariableResolver { const seenNames = new Set(); - return reversedVariables.filter(variable => { + const deduplicatedVariables = reversedVariables.filter(variable => { const provider = variable.provider || []; @@ -319,6 +319,23 @@ export class BaseVariableResolver { return false; }); + + return deduplicatedVariables.map(variable => { + if (!variable.usedBy || !Array.isArray(variable.usedBy)) { + return variable; + } + + const usedBy = filterUsedByForElement(variable, bo); + + if (usedBy.length === variable.usedBy.length) { + return variable; + } + + return { + ...variable, + usedBy: usedBy.length ? usedBy : undefined + }; + }); } _getScope(element, containerElement, variableName, checkYourself) { @@ -472,4 +489,42 @@ function isUsedOutsideOwnScope(variable) { return variable.usedBy.some(usedBy => { return usedBy && usedBy.id && !isElementInScope(usedBy, variable.scope); }); +} + +function filterUsedByForElement(variable, element) { + const names = variable.usedBy.filter(usage => typeof usage === 'string'); + const elements = variable.usedBy.filter(usage => usage && usage.id); + + if (!variable.scope) { + return [ ...names, ...elements.filter(usage => isElementInScope(usage, element)) ]; + } + + // Querying the variable's own scope: show local consumers. + if (element.id === variable.scope.id) { + return [ + ...names, + ...elements.filter(usage => isElementInScope(usage, variable.scope)) + ]; + } + + // Querying an ancestor scope: show consumers outside the variable's own scope. + if (isElementInScope(variable.scope, element)) { + return [ + ...names, + ...elements.filter(usage => + isElementInScope(usage, element) + && !isElementInScope(usage, variable.scope) + ) + ]; + } + + // Querying a child scope: show consumers in that child scope only. + if (isElementInScope(element, variable.scope)) { + return [ + ...names, + ...elements.filter(usage => isElementInScope(usage, element)) + ]; + } + + return names; } \ No newline at end of file From d9e87070c18cfa31696217fd7331c999e4520baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Steinr=C3=BCcken?= Date: Tue, 10 Mar 2026 13:16:02 +0100 Subject: [PATCH 12/12] adjust for https://github.com/bpmn-io/variable-resolver/pull/93 --- README.md | 35 ++- lib/base/VariableResolver.js | 200 ++++++++++++++---- lib/zeebe/VariableResolver.js | 105 ++++++++- lib/zeebe/util/feelUtility.js | 141 +++++++++++- test/spec/zeebe/Mappings.spec.js | 108 ++++++---- test/spec/zeebe/ZeebeVariableResolver.spec.js | 4 +- 6 files changed, 480 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index d464ad5..ec304fd 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ An extension for [bpmn-js](https://github.com/bpmn-io/bpmn-js) that makes the data model of the diagram available to other components. > [!NOTE] -> As of version `v3` this library exposes both written and consumed variables. +> As of version `v3` this library exposes both written and consumed variables, you can filter them via options. ## Usage @@ -42,14 +42,29 @@ const elementRegistry = modeler.get('elementRegistry'); // retrieve variables relevant to an element const task = elementRegistry.get('Task_1'); -// variables available in scope of +// default: variables relevant to in its visible scopes await variableResolver.getVariablesForElement(task); -// variables read by , excluding local ones -await variableResolver.getVariablesForElement(task, { read: true, local: false }); +// variables read by only +await variableResolver.getVariablesForElement(task, { + read: true, + written: false +}); // all variables written by -await variableResolver.getVariablesForElement(task, { written: true }); +await variableResolver.getVariablesForElement(task, { written: true, read: false }); + +// local variables only (scope === queried element) +await variableResolver.getVariablesForElement(task, { + local: true, + external: false +}); + +// non-local variables only (scope !== queried element) +await variableResolver.getVariablesForElement(task, { + local: false, + external: true +}); // retrieve all variables defined in a process const processElement = elementRegistry.get('Process_1'); @@ -58,6 +73,16 @@ const processElement = elementRegistry.get('Process_1'); await variableResolver.getProcessVariables(processElement); ``` +`getVariablesForElement(element, options)` supports five filter switches: + +| Option | Default | Description | +| --- | --- | --- | +| `read` | `true` | Include variables consumed by the queried element | +| `written` | `true` | Include variables written/created by the queried element | +| `local` | `true` | Include variables local to the queried element scope | +| `external` | `true` | Include variables outside the queried element scope | +| `outputMappings` | `true` | Count output-mapping reads as reads | + ### Adding a variable extractor To add your own variables, extend the `variableProvider` class in your extension. It only needs to implement the `getVariables` method, which takes an element as an argument and returns an array of variables you want to add to the scope of the element. The function can be asynchronous. diff --git a/lib/base/VariableResolver.js b/lib/base/VariableResolver.js index ed1468b..3966832 100644 --- a/lib/base/VariableResolver.js +++ b/lib/base/VariableResolver.js @@ -18,6 +18,21 @@ import { getParents } from './util/elementsUtil'; * @property {Array} origin * @property {ModdleElement} [scope] * @property {Array} provider + * @property {Array} [usedBy] Elements or variable names consuming this variable + * @property {Array} [readFrom] Source tags describing where this variable is read from + */ + +/** + * @typedef {Object} VariablesFilterOptions + * @property {boolean} [read=true] Include consumed variables + * @property {boolean} [written=true] Include variables written in the queried element + * @property {boolean} [local=true] Include variables in the queried element scope + * @property {boolean} [external=true] Include variables outside the queried element scope + * @property {boolean} [outputMappings=true] Include reads originating from output mappings + */ + +/** + * @typedef {ProcessVariable} AvailableVariable */ /** @@ -179,6 +194,18 @@ export class BaseVariableResolver { }); } } + + if (variable.readFrom) { + if (!existingVariable.readFrom) { + existingVariable.readFrom = [ ...variable.readFrom ]; + } else { + variable.readFrom.forEach(source => { + if (!existingVariable.readFrom.includes(source)) { + existingVariable.readFrom.push(source); + } + }); + } + } } else { mergedVariables.push(variable); } @@ -263,12 +290,18 @@ export class BaseVariableResolver { /** * Returns all variables in the scope of the given element. * + * All filter switches default to `true` + * + * Use `{ read: true, written: false }` to retrieve read-only variables. + * * @async * @param {ModdleElement} element - * @returns {Promise>} variables + * @param {VariablesFilterOptions} [options] + * @returns {Promise>} variables */ - async getVariablesForElement(element) { + async getVariablesForElement(element, options = {}) { const bo = getBusinessObject(element); + const filterOptions = normalizeFilterOptions(options); const root = getRootElement(bo); const allVariables = await this.getProcessVariables(root); @@ -320,14 +353,14 @@ export class BaseVariableResolver { return false; }); - return deduplicatedVariables.map(variable => { + const projectedScopedVariables = deduplicatedVariables.map(variable => { if (!variable.usedBy || !Array.isArray(variable.usedBy)) { return variable; } const usedBy = filterUsedByForElement(variable, bo); - if (usedBy.length === variable.usedBy.length) { + if (isSameUsageList(variable.usedBy, usedBy)) { return variable; } @@ -336,32 +369,41 @@ export class BaseVariableResolver { usedBy: usedBy.length ? usedBy : undefined }; }); + + const consumedVariables = allVariables.filter(variable => { + return !variable.scope + && Array.isArray(variable.usedBy) + && variable.usedBy.some(usage => usage && usage.id === bo.id); + }); + + let candidates = projectedScopedVariables; + + if (filterOptions.read && !filterOptions.written) { + candidates = [ ...projectedScopedVariables, ...consumedVariables ]; + } else if (filterOptions.read && filterOptions.written && !projectedScopedVariables.length) { + + // Preserve current default behavior: only fall back to consumed variables + // when no scoped/ancestor variables are available. + candidates = consumedVariables; + } + + return candidates.filter(variable => { + const isLocal = !!(variable.scope && variable.scope.id === bo.id); + const hasReadUsage = !!(variable.usedBy && variable.usedBy.length); + const readSources = Array.isArray(variable.readFrom) ? variable.readFrom : []; + const hasOutputMappingRead = hasReadSource(readSources, 'output-mapping'); + const hasNonOutputRead = hasReadUsage && (!readSources.length || readSources.some(source => source !== 'output-mapping')); + const isRead = hasNonOutputRead || (filterOptions.outputMappings && hasOutputMappingRead); + const isWritten = !!(variable.origin && variable.origin.some(origin => origin && origin.id === bo.id)); + + return matchesTypeFilter(isRead, isWritten, filterOptions) + && matchesScopeFilter(isLocal, filterOptions); + }); } _getScope(element, containerElement, variableName, checkYourself) { throw new Error('not implemented VariableResolver#_getScope'); } - - /** - * Returns consumed variables for an element — variables - * the element needs as input for its expressions and mappings. - * - * Uses `getVariables()` instead of `getVariablesForElement()` to - * bypass the name-based deduplication that would drop requirement - * entries for variables that also exist in ancestor scopes. - * - * @param {Object} element - * @returns {Promise>} - */ - async getConsumedVariablesForElement(element) { - const allVariablesByRoot = await this.parsedVariables.get(); - const allVariables = Object.values(allVariablesByRoot).flat(); - - return allVariables.filter(v => - !v.scope - && v.usedBy && v.usedBy.some((a) => a.id === element.id) - ); - } } BaseVariableResolver.$inject = [ 'eventBus', 'bpmnjs' ]; @@ -496,35 +538,109 @@ function filterUsedByForElement(variable, element) { const elements = variable.usedBy.filter(usage => usage && usage.id); if (!variable.scope) { - return [ ...names, ...elements.filter(usage => isElementInScope(usage, element)) ]; + return elements; } // Querying the variable's own scope: show local consumers. if (element.id === variable.scope.id) { - return [ - ...names, - ...elements.filter(usage => isElementInScope(usage, variable.scope)) - ]; + const localConsumers = elements.filter(usage => isElementInScope(usage, variable.scope)); + + if (localConsumers.length) { + return localConsumers; + } + + // For local mapping dependencies represented as names, expose the + // querying element as the consumer. + return names.length ? [ element ] : []; } // Querying an ancestor scope: show consumers outside the variable's own scope. if (isElementInScope(variable.scope, element)) { - return [ - ...names, - ...elements.filter(usage => - isElementInScope(usage, element) - && !isElementInScope(usage, variable.scope) - ) - ]; + return elements.filter(usage => + isElementInScope(usage, element) + && !isElementInScope(usage, variable.scope) + ); } // Querying a child scope: show consumers in that child scope only. if (isElementInScope(element, variable.scope)) { - return [ - ...names, - ...elements.filter(usage => isElementInScope(usage, element)) - ]; + return elements.filter(usage => isElementInScope(usage, element)); } - return names; + return []; +} + +function normalizeFilterOptions(options) { + options = options || {}; + + return { + read: options.read !== false, + written: options.written !== false, + local: options.local !== false, + external: options.external !== false, + outputMappings: options.outputMappings !== false + }; +} + +function matchesTypeFilter(isRead, isWritten, options) { + if (options.read && options.written) { + return true; + } + + if (options.read) { + return isRead; + } + + if (options.written) { + return isWritten; + } + + return false; +} + +function matchesScopeFilter(isLocal, options) { + if (options.local && options.external) { + return true; + } + + if (options.local) { + return isLocal; + } + + if (options.external) { + return !isLocal; + } + + return false; +} + +function isSameUsageList(usagesA, usagesB) { + if (!Array.isArray(usagesA) || !Array.isArray(usagesB)) { + return false; + } + + if (usagesA.length !== usagesB.length) { + return false; + } + + const keysA = usagesA.map(getUsageKey).sort(); + const keysB = usagesB.map(getUsageKey).sort(); + + return keysA.every((key, index) => key === keysB[index]); +} + +function getUsageKey(usage) { + if (typeof usage === 'string') { + return `name:${usage}`; + } + + if (usage && usage.id) { + return `id:${usage.id}`; + } + + return String(usage); +} + +function hasReadSource(readFrom, sourceName) { + return Array.isArray(readFrom) && readFrom.includes(sourceName); } \ No newline at end of file diff --git a/lib/zeebe/VariableResolver.js b/lib/zeebe/VariableResolver.js index 9e2d7fd..0ae9bd5 100644 --- a/lib/zeebe/VariableResolver.js +++ b/lib/zeebe/VariableResolver.js @@ -2,7 +2,8 @@ import { getProcessVariables, getScope } from '@bpmn-io/extract-process-variable import { BaseVariableResolver } from '../base/VariableResolver'; import { parseVariables, - getElementNamesToRemove + getElementNamesToRemove, + extractConsumedVariablesFromElements } from './util/feelUtility'; import { getBusinessObject, @@ -25,12 +26,37 @@ export default class ZeebeVariableResolver extends BaseVariableResolver { this._baseExtractor = getProcessVariables; this._getScope = getScope; - eventBus.on('variableResolver.parseVariables', HIGHER_PRIORITY, this._resolveVariables); + eventBus.on('variableResolver.parseVariables', HIGHER_PRIORITY, this._resolveVariables.bind(this)); eventBus.on('variableResolver.parseVariables', HIGH_PRIORITY, this._expandVariables); } - async getVariablesForElement(element, moddleElement) { - const variables = await super.getVariablesForElement(element); + /** + * Returns variables for an element. + * + * Supported signatures: + * - `getVariablesForElement(element, options)` + * - `getVariablesForElement(element, moddleElement, options)` + * + * The second signature can be used to remove pre-defined mapping variables + * from the result based on the given moddle element. + * + * @param {ModdleElement} element + * @param {ModdleElement|Object} [optionsOrModdleElement] A moddle element or filter options + * @param {Object} [options] Filter options when a moddle element is provided + * @param {boolean} [options.read] + * @param {boolean} [options.written] + * @param {boolean} [options.local] + * @param {boolean} [options.external] + * @param {boolean} [options.outputMappings] + * @returns {Promise>} + */ + async getVariablesForElement(element, optionsOrModdleElement, options) { + const { + moddleElement, + filterOptions + } = normalizeGetVariablesForElementArguments(optionsOrModdleElement, options); + + const variables = await super.getVariablesForElement(element, filterOptions); const bo = getBusinessObject(element); @@ -49,7 +75,7 @@ export default class ZeebeVariableResolver extends BaseVariableResolver { return variables.filter(v => { // Keep all variables that are also defined in other elements - if (v.origin.length > 1 || v.origin[0] !== bo) { + if (!Array.isArray(v.origin) || v.origin.length > 1 || v.origin[0] !== bo) { return true; } @@ -95,20 +121,85 @@ export default class ZeebeVariableResolver extends BaseVariableResolver { */ _resolveVariables(e, context) { const rawVariables = context.variables; + const definitions = this._bpmnjs.getDefinitions(); const mappedVariables = {}; for (const key in rawVariables) { const variables = rawVariables[key]; const { resolvedVariables, consumedVariables } = parseVariables(variables); - - mappedVariables[key] = [ ...variables, ...resolvedVariables, ...consumedVariables ]; + const rootElement = getRootElementById(definitions, key); + const elementConsumedVariables = rootElement + ? extractConsumedVariablesFromElements(getFlowElements(rootElement)) + : []; + + mappedVariables[key] = [ + ...variables, + ...resolvedVariables, + ...consumedVariables, + ...elementConsumedVariables + ]; } context.variables = mappedVariables; } } +function normalizeGetVariablesForElementArguments(optionsOrModdleElement, options) { + const looksLikeOptions = isFilterOptions(optionsOrModdleElement); + + if (looksLikeOptions) { + return { + moddleElement: null, + filterOptions: optionsOrModdleElement + }; + } + + return { + moddleElement: optionsOrModdleElement || null, + filterOptions: isFilterOptions(options) ? options : undefined + }; +} + +function isFilterOptions(value) { + if (!value || typeof value !== 'object') { + return false; + } + + return [ 'read', 'written', 'local', 'external', 'outputMappings' ].some(key => key in value); +} + +function getRootElementById(definitions, id) { + if (!definitions || !Array.isArray(definitions.rootElements)) { + return null; + } + + return definitions.rootElements.find(root => root && root.id === id) || null; +} + +function getFlowElements(rootElement) { + const flowElements = []; + const queue = [ rootElement ]; + + while (queue.length) { + const element = queue.shift(); + + if (!element || !Array.isArray(element.flowElements)) { + continue; + } + + for (const flowElement of element.flowElements) { + flowElements.push(flowElement); + + if (Array.isArray(flowElement.flowElements)) { + queue.push(flowElement); + } + } + } + + return flowElements; +} + /** * @param {Array} variables * diff --git a/lib/zeebe/util/feelUtility.js b/lib/zeebe/util/feelUtility.js index 707486c..86b6b7c 100644 --- a/lib/zeebe/util/feelUtility.js +++ b/lib/zeebe/util/feelUtility.js @@ -81,7 +81,7 @@ export function parseVariables(variables) { const { consumed: consumedVariables, localUsages } = buildConsumedVariables(analysisResults); // Step 4 - Annotate locally-provided variables with usedBy information - for (const { variableName, targetName, origin } of localUsages) { + for (const { variableName, targetName, origin, expressionType } of localUsages) { const variable = findNearestScopedVariable(variables, variableName, origin); if (!variable) { @@ -99,6 +99,14 @@ export function parseVariables(variables) { if (!hasUsage(variable.usedBy, usage)) { variable.usedBy.push(usage); } + + if (!variable.readFrom) { + variable.readFrom = []; + } + + if (!variable.readFrom.includes(expressionType)) { + variable.readFrom.push(expressionType); + } } // Step 5 - Bridge consumed usages back to uniquely scoped declarations @@ -112,12 +120,17 @@ function annotateConsumedUsagesToScopedVariables(variables, consumedVariables) { const candidateNames = getConsumedVariableCandidateNames(consumedVariable); for (const candidateName of candidateNames) { - annotateConsumedUsagesToScopedVariableByName(variables, candidateName, consumedVariable.usedBy || []); + annotateConsumedUsagesToScopedVariableByName( + variables, + candidateName, + consumedVariable.usedBy || [], + consumedVariable.readFrom || [] + ); } } } -function annotateConsumedUsagesToScopedVariableByName(variables, variableName, usages) { +function annotateConsumedUsagesToScopedVariableByName(variables, variableName, usages, readFrom) { const scopedCandidates = variables.filter(v => v.name === variableName && v.scope); if (scopedCandidates.length !== 1) { @@ -130,6 +143,10 @@ function annotateConsumedUsagesToScopedVariableByName(variables, variableName, u scopedVariable.usedBy = []; } + if (!scopedVariable.readFrom) { + scopedVariable.readFrom = []; + } + for (const usage of usages) { if (!usage || !usage.id) { continue; @@ -139,6 +156,12 @@ function annotateConsumedUsagesToScopedVariableByName(variables, variableName, u scopedVariable.usedBy.push(usage); } } + + readFrom.forEach(source => { + if (!scopedVariable.readFrom.includes(source)) { + scopedVariable.readFrom.push(source); + } + }); } function getConsumedVariableCandidateNames(consumedVariable) { @@ -418,8 +441,8 @@ function selectPrimaryExpression(expressionCandidates) { /** * Analyze candidate expressions for consumed variables. * - * Output mappings are ignored because they do not consume variables in the - * local execution scope. + * Includes output mappings so callers can decide whether to show or filter + * output-mapping reads. * * @param {Array<{ type: string, value: string }>} expressionCandidates * @param {string} primaryExpression @@ -430,10 +453,6 @@ function collectRequirementAnalyses(expressionCandidates, primaryExpression, pri const requirementAnalyses = []; for (const { type, value } of expressionCandidates) { - if (type === 'output-mapping') { - continue; - } - const analysis = value === primaryExpression ? primaryExpressionDetails : getExpressionDetails(value); @@ -754,6 +773,88 @@ export function getElementNamesToRemove(moddleElement, inputOutput) { return namesToFilter; } +/** + * Extract consumed variables from FEEL expressions that are not represented by + * variable mappings, e.g. sequence flow conditions and receive-task + * subscription keys. + * + * @param {Array} elements + * @returns {Array} + */ +export function extractConsumedVariablesFromElements(elements = []) { + const consumedVariables = new Map(); + + const elementExpressions = getElementExpressions(elements); + + for (const { element, expression } of elementExpressions) { + const { inputs } = getExpressionDetails(expression); + + for (const inputVar of inputs) { + const existingVariable = consumedVariables.get(inputVar.name); + + if (!existingVariable) { + consumedVariables.set(inputVar.name, { + name: inputVar.name, + origin: undefined, + scope: undefined, + entries: inputVar.entries || [], + usedBy: [ element ] + }); + + continue; + } + + if (inputVar.entries && inputVar.entries.length) { + mergeEntries(existingVariable, { entries: inputVar.entries }); + } + + if (!hasUsage(existingVariable.usedBy, element)) { + existingVariable.usedBy.push(element); + } + } + } + + return Array.from(consumedVariables.values()); +} + +function getElementExpressions(elements) { + const expressions = []; + + for (const element of elements) { + if (is(element, 'bpmn:SequenceFlow')) { + const conditionExpression = element.conditionExpression && element.conditionExpression.body; + + if (typeof conditionExpression === 'string' && conditionExpression.trim()) { + expressions.push({ + element, + expression: conditionExpression + }); + } + } + + if (is(element, 'bpmn:ReceiveTask')) { + const message = element.messageRef; + + if (!message) { + continue; + } + + const subscription = getExtensionElementsList(message, 'zeebe:Subscription')[0]; + + if (!subscription || !subscription.correlationKey) { + continue; + } + + expressions.push({ + element, + expression: subscription.correlationKey + }); + } + } + + return expressions; +} + /** * Build consumed variables from pre-collected analysis results. * @@ -775,12 +876,25 @@ function buildConsumedVariables(analysisResults) { for (const inputVar of inputs) { + // Output mappings should annotate local read usage but not create global + // consumed variables. + if (expressionType === 'output-mapping') { + localUsages.push({ + variableName: inputVar.name, + origin, + targetName, + expressionType + }); + continue; + } + // Track locally-provided variables that are used by output expressions if (availableLocalTargets.has(inputVar.name)) { localUsages.push({ variableName: inputVar.name, origin, - targetName + targetName, + expressionType }); continue; } @@ -792,7 +906,8 @@ function buildConsumedVariables(analysisResults) { name: inputVar.name, origin: undefined, entries: inputVar.entries || [], - usedBy: [ origin ] + usedBy: [ origin ], + readFrom: [ expressionType ] }; } else { if (!consumedVariables[key].usedBy.includes(targetName)) { @@ -804,6 +919,10 @@ function buildConsumedVariables(analysisResults) { if (inputVar.entries && inputVar.entries.length) { mergeEntries(consumedVariables[key], { entries: inputVar.entries }); } + + if (!consumedVariables[key].readFrom.includes(expressionType)) { + consumedVariables[key].readFrom.push(expressionType); + } } } } diff --git a/test/spec/zeebe/Mappings.spec.js b/test/spec/zeebe/Mappings.spec.js index de24ef7..f58c631 100644 --- a/test/spec/zeebe/Mappings.spec.js +++ b/test/spec/zeebe/Mappings.spec.js @@ -899,7 +899,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('SimpleTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableEqual([ @@ -915,7 +915,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('NestedTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableInclude({ @@ -932,7 +932,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('MultiInputTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then - y is used in both input mappings but should only appear once const yVars = variables.filter(v => v.name === 'y'); @@ -946,7 +946,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('MultiInputTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableInclude([ @@ -963,7 +963,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('MergedEntriesTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then - a.b and a.c should result in a: { entries: [b, c] } expect(variables).to.variableInclude({ @@ -982,7 +982,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('SimpleTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then - a originates from SimpleTask (the task with the input mapping) const a = variables.find(v => v.name === 'a'); @@ -1007,8 +1007,8 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const scopedA = variables.find(v => v.name === 'a' && v.scope); expect(scopedA).to.exist; - // and getConsumedVariablesForElement should have the unscoped consumed variable - const consumed = await variableResolver.getConsumedVariablesForElement(task); + // and a read-only query should have the unscoped consumed variable + const consumed = await getReadVariablesForElement(variableResolver, task); const unscopedA = consumed.find(v => v.name === 'a' && !v.scope); expect(unscopedA).to.exist; })); @@ -1028,8 +1028,8 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const outputTask = elementRegistry.get('OutputTask'); // when - const inputReqs = await variableResolver.getConsumedVariablesForElement(inputTask); - const outputReqs = await variableResolver.getConsumedVariablesForElement(outputTask); + const inputReqs = await getReadVariablesForElement(variableResolver, inputTask); + const outputReqs = await getReadVariablesForElement(variableResolver, outputTask); // then - foo is used in the input mapping of both tasks expect(inputReqs).to.variableInclude({ name: 'foo' }); @@ -1037,26 +1037,26 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { })); - it('should return foo as consumed variable for InputTask via getConsumedVariablesForElement', inject(async function(variableResolver, elementRegistry) { + it('should return foo as read variable for InputTask', inject(async function(variableResolver, elementRegistry) { // given const task = elementRegistry.get('InputTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then expect(requirements).to.variableInclude({ name: 'foo' }); })); - it('should return foo as consumed variable for OutputTask via getConsumedVariablesForElement', inject(async function(variableResolver, elementRegistry) { + it('should return foo as read variable for OutputTask', inject(async function(variableResolver, elementRegistry) { // given const task = elementRegistry.get('OutputTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then expect(requirements).to.variableInclude({ name: 'foo' }); @@ -1076,7 +1076,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('firstTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableEqual([ @@ -1092,7 +1092,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('secondTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableEqual([ @@ -1132,7 +1132,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('scriptWithInputs'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableInclude([ @@ -1152,7 +1152,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('scriptWithoutInputs'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableInclude([ @@ -1168,7 +1168,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('chainedInputMappings'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableInclude({ name: 'processVar3' }); @@ -1185,7 +1185,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('shadowingTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableInclude({ name: 'a' }); @@ -1198,7 +1198,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('shadowingChainedTask'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableInclude({ name: 'a' }); @@ -1214,7 +1214,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('scriptUsesSecondInput'); // when - const variables = await variableResolver.getConsumedVariablesForElement(task); + const variables = await getReadVariablesForElement(variableResolver, task); // then expect(variables).to.variableEqual([ @@ -1239,8 +1239,8 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const localA = variables.find(v => v.name === 'localA'); const localB = variables.find(v => v.name === 'localB'); - expect(localA.usedBy).to.eql([ 'scriptResult' ]); - expect(localB.usedBy).to.eql([ 'scriptResult' ]); + expect(localA.usedBy.map(usage => usage.id)).to.eql([ task.id ]); + expect(localB.usedBy.map(usage => usage.id)).to.eql([ task.id ]); })); @@ -1256,10 +1256,9 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const localC = variables.find(v => v.name === 'localC'); const localD = variables.find(v => v.name === 'localD'); expect(localC).to.exist; - expect(localC.usedBy).to.include('localD'); - expect(localC.usedBy).to.include('chainedResult'); + expect(localC.usedBy.map(usage => usage.id)).to.eql([ task.id ]); expect(localD).to.exist; - expect(localD.usedBy).to.eql([ 'chainedResult' ]); + expect(localD.usedBy.map(usage => usage.id)).to.eql([ task.id ]); })); @@ -1280,7 +1279,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { }); - describe('#getConsumedVariablesForElement', function() { + describe('#getVariablesForElement (read filter)', function() { describe('with input mappings', function() { @@ -1293,7 +1292,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('SimpleTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then - both 'a' and 'b' are consumed variables for SimpleTask expect(requirements).to.variableEqual([ @@ -1309,7 +1308,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('NestedTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then expect(requirements).to.variableEqual([ @@ -1324,7 +1323,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('MultiInputTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then expect(requirements).to.variableInclude([ @@ -1341,7 +1340,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const process = elementRegistry.get('Process_1'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(process); + const requirements = await getReadVariablesForElement(variableResolver, process); // then expect(requirements).to.be.an('array').that.is.empty; @@ -1354,7 +1353,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('SimpleTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then - should not include variables from MultiInputTask or NestedTask expect(requirements).to.variableEqual([ @@ -1371,7 +1370,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('MergedEntriesTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then expect(requirements).to.variableEqual([ @@ -1393,7 +1392,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('firstTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then expect(requirements).to.variableInclude([ @@ -1409,7 +1408,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { const task = elementRegistry.get('secondTask'); // when - const requirements = await variableResolver.getConsumedVariablesForElement(task); + const requirements = await getReadVariablesForElement(variableResolver, task); // then expect(requirements).to.variableInclude([ @@ -1433,7 +1432,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { it('should only return process variables as consumed variables, not locally mapped ones', inject(async function(variableResolver, elementRegistry) { // when - const requirements = await variableResolver.getConsumedVariablesForElement( + const requirements = await getReadVariablesForElement(variableResolver, elementRegistry.get('scriptWithInputs') ); @@ -1448,7 +1447,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { it('should return all script variables for task without input mappings', inject(async function(variableResolver, elementRegistry) { // when - const requirements = await variableResolver.getConsumedVariablesForElement( + const requirements = await getReadVariablesForElement(variableResolver, elementRegistry.get('scriptWithoutInputs') ); @@ -1463,7 +1462,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { it('should handle chained input mappings respecting order', inject(async function(variableResolver, elementRegistry) { // when - const requirements = await variableResolver.getConsumedVariablesForElement( + const requirements = await getReadVariablesForElement(variableResolver, elementRegistry.get('chainedInputMappings') ); @@ -1477,7 +1476,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { it('should keep shadowed variable as requirement when mapping a to a', inject(async function(variableResolver, elementRegistry) { // when - const requirements = await variableResolver.getConsumedVariablesForElement( + const requirements = await getReadVariablesForElement(variableResolver, elementRegistry.get('shadowingTask') ); @@ -1491,7 +1490,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { it('should handle shadowing with chained mappings', inject(async function(variableResolver, elementRegistry) { // when - const requirements = await variableResolver.getConsumedVariablesForElement( + const requirements = await getReadVariablesForElement(variableResolver, elementRegistry.get('shadowingChainedTask') ); @@ -1505,7 +1504,7 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { it('should allow script to use all input targets regardless of order', inject(async function(variableResolver, elementRegistry) { // when - const requirements = await variableResolver.getConsumedVariablesForElement( + const requirements = await getReadVariablesForElement(variableResolver, elementRegistry.get('scriptUsesSecondInput') ); @@ -1523,17 +1522,24 @@ describe('ZeebeVariableResolver - Variable Mappings', function() { beforeEach(bootstrap(emptyXML)); - it('should return empty array when getVariables fails', inject(async function(variableResolver, elementRegistry) { + it('should reject when getVariables fails', inject(async function(variableResolver, elementRegistry) { // given const process = elementRegistry.get('Process_1'); sinon.stub(variableResolver, 'getVariables').rejects(new Error('test error')); // when - const requirements = await variableResolver.getConsumedVariablesForElement(process); - // then - expect(requirements).to.be.an('array').that.is.empty; + let error; + + try { + await getReadVariablesForElement(variableResolver, process); + } catch (err) { + error = err; + } + + expect(error).to.exist; + expect(error.message).to.eql('test error'); variableResolver.getVariables.restore(); })); @@ -1560,6 +1566,16 @@ const createProvider = function({ variables, variableResolver, origin }) { }(variableResolver); }; +async function getReadVariablesForElement(variableResolver, element) { + const variables = await variableResolver.getVariablesForElement(element, { + read: true, + written: false + }); + + // Preserve old consumed-only test semantics: consumed variables are unscoped. + return variables.filter(variable => !variable.scope); +} + function toVariableFormat(variables) { return Object.keys(variables).map(v => { return { diff --git a/test/spec/zeebe/ZeebeVariableResolver.spec.js b/test/spec/zeebe/ZeebeVariableResolver.spec.js index 328d972..b5b7de4 100644 --- a/test/spec/zeebe/ZeebeVariableResolver.spec.js +++ b/test/spec/zeebe/ZeebeVariableResolver.spec.js @@ -2631,7 +2631,7 @@ describe('ZeebeVariableResolver', function() { // then expect(variables).to.variableEqual([ { name: 'taskResult' }, - { name: 'approved', usedBy: [ 'Task_1', 'Task_2' ] } + { name: 'approved', usedBy: [ 'Task_2' ] } ]); })); @@ -2647,7 +2647,7 @@ describe('ZeebeVariableResolver', function() { // then expect(variables).to.variableEqual([ { name: 'taskResult' }, - { name: 'approved', usedBy: [ 'Task_1', 'Task_2' ] } + { name: 'approved', usedBy: [ 'Task_1' ] } ]); }));