diff --git a/README.md b/README.md index 1403fc8..ec304fd 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, you can filter them via options. ## Usage @@ -30,19 +33,56 @@ 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 +// default: variables relevant to in its visible scopes +await variableResolver.getVariablesForElement(task); + +// variables read by only +await variableResolver.getVariablesForElement(task, { + read: true, + written: false +}); + +// all variables written by +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'); + +// returns all variables for the process (unfiltered), for local processing +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. @@ -76,11 +116,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'); diff --git a/lib/base/VariableResolver.js b/lib/base/VariableResolver.js index 9bd36e4..3966832 100644 --- a/lib/base/VariableResolver.js +++ b/lib/base/VariableResolver.js @@ -16,8 +16,23 @@ import { getParents } from './util/elementsUtil'; /** * @typedef {AdditionalVariable} ProcessVariable * @property {Array} origin - * @property {ModdleElement} scope + * @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 */ /** @@ -159,12 +174,38 @@ 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); + } + }); + } + } + + 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); } @@ -249,38 +290,56 @@ 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 {Array} 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); // (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; }); }); - 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 deduplicatedVariables = 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; } @@ -293,12 +352,58 @@ export class BaseVariableResolver { return false; }); + + const projectedScopedVariables = deduplicatedVariables.map(variable => { + if (!variable.usedBy || !Array.isArray(variable.usedBy)) { + return variable; + } + + const usedBy = filterUsedByForElement(variable, bo); + + if (isSameUsageList(variable.usedBy, usedBy)) { + return variable; + } + + return { + ...variable, + 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'); } - } BaseVariableResolver.$inject = [ 'eventBus', 'bpmnjs' ]; @@ -396,4 +501,146 @@ 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); + }); +} + +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 elements; + } + + // Querying the variable's own scope: show local consumers. + if (element.id === variable.scope.id) { + 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 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 elements.filter(usage => isElementInScope(usage, element)); + } + + 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 ca7a413..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 newVariables = parseVariables(variables); - - mappedVariables[key] = [ ...variables, ...newVariables ]; + const { resolvedVariables, consumedVariables } = parseVariables(variables); + 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 * @@ -146,6 +237,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 3a4392f..86b6b7c 100644 --- a/lib/zeebe/util/feelUtility.js +++ b/lib/zeebe/util/feelUtility.js @@ -4,37 +4,232 @@ 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, expressionType } of localUsages) { + const variable = findNearestScopedVariable(variables, variableName, origin); + + 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); + } + + if (!variable.readFrom) { + variable.readFrom = []; + } + + if (!variable.readFrom.includes(expressionType)) { + variable.readFrom.push(expressionType); + } + } + + // 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 candidateNames = getConsumedVariableCandidateNames(consumedVariable); + + for (const candidateName of candidateNames) { + annotateConsumedUsagesToScopedVariableByName( + variables, + candidateName, + consumedVariable.usedBy || [], + consumedVariable.readFrom || [] + ); + } + } +} + +function annotateConsumedUsagesToScopedVariableByName(variables, variableName, usages, readFrom) { + const scopedCandidates = variables.filter(v => v.name === variableName && v.scope); + + if (scopedCandidates.length !== 1) { + return; + } + + const scopedVariable = scopedCandidates[0]; + + if (!scopedVariable.usedBy) { + scopedVariable.usedBy = []; + } + + if (!scopedVariable.readFrom) { + scopedVariable.readFrom = []; + } + + for (const usage of usages) { + if (!usage || !usage.id) { + continue; + } + + if (!hasUsage(scopedVariable.usedBy, usage)) { + scopedVariable.usedBy.push(usage); + } + } + + readFrom.forEach(source => { + if (!scopedVariable.readFrom.includes(source)) { + scopedVariable.readFrom.push(source); + } + }); +} + +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) { + 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; + }); +} + +/** + * 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 +268,7 @@ function resolveReferences(variablesToResolve, allVariables) { const rootContext = { name: 'OuterContext', - entries: toOptimizedFormat(variablesWithoutMappings) + entries: toOptimizedFormat(variablesWithoutMappings.filter(v => v.scope)) }; const newVariables = []; @@ -205,56 +400,139 @@ 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 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)); +} + +/** + * Pick the highest-priority expression from expression candidates. + * + * @param {Array<{ type: string, value: string }>} expressionCandidates + * @returns {string|undefined} */ -function getExpressionDetails(variable, origin) { +function selectPrimaryExpression(expressionCandidates) { + const [ primary ] = expressionCandidates; - // 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); + return primary && primary.value; +} - if (isNil(expression)) { - return; +/** + * Analyze candidate expressions for consumed variables. + * + * 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 + * @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) { + 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 +552,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 +721,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 +772,201 @@ 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. + * + * @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) { + const availableLocalTargets = getAvailableLocalTargets( + origin, + expressionType, + targetName, + inputMappingTargetsCache + ); + + 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, + expressionType + }); + continue; + } + + const key = `${inputVar.name}__${origin.id}`; + + if (!consumedVariables[key]) { + consumedVariables[key] = { + name: inputVar.name, + origin: undefined, + entries: inputVar.entries || [], + usedBy: [ origin ], + readFrom: [ expressionType ] + }; + } 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 }); + } + + if (!consumedVariables[key].readFrom.includes(expressionType)) { + consumedVariables[key].readFrom.push(expressionType); + } + } + } + } + + 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); +} + +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/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/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/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/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/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/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/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/Mappings.spec.js b/test/spec/zeebe/Mappings.spec.js index 1c4a6e8..f58c631 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,667 @@ 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 getReadVariablesForElement(variableResolver, task); + + // then + expect(variables).to.variableEqual([ + { name: 'a', usedBy: [ 'SimpleTask' ] }, + { name: 'b', usedBy: [ 'SimpleTask' ] } + ]); + })); + + + it('should extract input variables with nested properties', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('NestedTask'); + + // when + const variables = await getReadVariablesForElement(variableResolver, task); + + // then + expect(variables).to.variableInclude({ + name: 'order', + entries: [ { name: 'items' } ], + usedBy: [ 'NestedTask' ] + }); + })); + + + it('should deduplicate input variables across mappings', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('MultiInputTask'); + + // when + 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'); + expect(yVars).to.have.length(1); + })); + + + 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 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; + })); + + }); + + + 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 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' }); + expect(outputReqs).to.variableInclude({ name: 'foo' }); + })); + + + it('should return foo as read variable for InputTask', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('InputTask'); + + // when + const requirements = await getReadVariablesForElement(variableResolver, task); + + // then + expect(requirements).to.variableInclude({ name: 'foo' }); + })); + + + it('should return foo as read variable for OutputTask', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('OutputTask'); + + // when + const requirements = await getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, task); + + // then + expect(variables).to.variableEqual([ + { name: 'a', usedBy: [ 'firstTask' ] }, + { name: 'b', usedBy: [ 'firstTask' ] } + ]); + })); + + + it('should extract input variables from second script task', inject(async function(variableResolver, elementRegistry) { + + // given + const task = elementRegistry.get('secondTask'); + + // when + const variables = await getReadVariablesForElement(variableResolver, task); + + // then + expect(variables).to.variableEqual([ + { name: 'd', usedBy: [ 'secondTask' ] }, + { name: 'f', usedBy: [ '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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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.map(usage => usage.id)).to.eql([ task.id ]); + expect(localB.usedBy.map(usage => usage.id)).to.eql([ task.id ]); + })); + + + 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.map(usage => usage.id)).to.eql([ task.id ]); + expect(localD).to.exist; + expect(localD.usedBy.map(usage => usage.id)).to.eql([ task.id ]); + })); + + + 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('#getVariablesForElement (read filter)', 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, task); + + // then - should not include variables from MultiInputTask or NestedTask + expect(requirements).to.variableEqual([ + { name: 'a' }, + { name: 'b' } + ]); + })); + + + 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, 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 getReadVariablesForElement(variableResolver, + 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 getReadVariablesForElement(variableResolver, + 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 getReadVariablesForElement(variableResolver, + 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 getReadVariablesForElement(variableResolver, + 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 getReadVariablesForElement(variableResolver, + 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 getReadVariablesForElement(variableResolver, + elementRegistry.get('scriptUsesSecondInput') + ); + + // then + expect(requirements).to.variableEqual([ + { name: 'def' } + ]); + })); + + }); + + + describe('error handling', function() { + + beforeEach(bootstrap(emptyXML)); + + + 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 + // then + 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(); + })); + + }); + + }); + }); // helpers ////////////////////// @@ -899,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/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); + }); + +}); diff --git a/test/spec/zeebe/ZeebeVariableResolver.spec.js b/test/spec/zeebe/ZeebeVariableResolver.spec.js index f6e3536..b5b7de4 100644 --- a/test/spec/zeebe/ZeebeVariableResolver.spec.js +++ b/test/spec/zeebe/ZeebeVariableResolver.spec.js @@ -31,6 +31,11 @@ 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'; +import filteringXML from 'test/fixtures/zeebe/filtering.bpmn'; import VariableProvider from 'lib/VariableProvider'; import { getInputOutput } from '../../../lib/base/util/ExtensionElementsUtil'; @@ -2602,6 +2607,335 @@ 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' ] } + ]); + })); + + }); + + + 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, { + 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' } + ]); + })); + + }); + + + 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 //////////////////////