< Summary

Information
Class: Elsa.Bpmn.Interchange.Binding.BpmnWorkBinder
Assembly: Elsa.Bpmn.Interchange
File(s): /home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnWorkBinder.cs
Line coverage
98%
Covered lines: 57
Uncovered lines: 1
Coverable lines: 58
Total lines: 188
Line coverage: 98.2%
Branch coverage
90%
Covered branches: 29
Total branches: 32
Branch coverage: 90.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
Bind(...)100%11100%
BindScope(...)87.5%88100%
CreateActivity(...)92.85%141495%
ReadDeclaredActivity(...)75%44100%
RefuseUnusedDeclarations(...)100%44100%
IsoDurationOf(...)100%11100%
CalledElementOf(...)100%22100%

File(s)

/home/runner/work/elsa-core/elsa-core/src/modules/Elsa.Bpmn.Interchange/Binding/BpmnWorkBinder.cs

#LineLine coverage
 1using System.Xml;
 2using Bpmn.Interchange;
 3using Bpmn.Model;
 4using Elsa.Bpmn.Activities;
 5using Elsa.Bpmn.Interchange.Exceptions;
 6using Elsa.Scheduling.Activities;
 7using Elsa.Workflows;
 8using Elsa.Workflows.Memory;
 9using Elsa.Workflows.Runtime.Activities;
 10
 11namespace Elsa.Bpmn.Interchange.Binding;
 12
 13/// <summary>
 14/// Turns the reader's <see cref="BpmnWorkBinding"/> declarations into the Elsa activities a
 15/// <see cref="BpmnProcess"/> scope runs. This is the seam where BPMN vocabulary becomes Elsa work.
 16/// </summary>
 17/// <remarks>
 18/// <para>
 19/// Six of the seven binding kinds bind to an activity this repository already has. Only
 20/// <see cref="BpmnWorkBinding.UnboundTask"/> is an authoring decision, which is right: BPMN genuinely does not say
 21/// what a <c>serviceTask</c> does. Its answer is read from the <c>elsa:activityBinding</c> extension element inside
 22/// the document — see <see cref="BpmnActivityBindingFormat"/>, including what exporting one discloses.
 23/// </para>
 24/// <para>
 25/// <b>Every binding for a scope is bound, whatever its slot.</b> A <see cref="BpmnBindingSlot.ScopeListener"/>
 26/// binding — the listener an event subprocess arms while its enclosing scope runs — is an entry in the same
 27/// <see cref="BpmnProcess.WorkBindings"/> map as any other, under its own binding ref. It needs no special case here
 28/// because the interpreter arms it at scope start on its own; singling it out would be a second code path with
 29/// nothing different to do.
 30/// </para>
 31/// <para>
 32/// <b>Distinct activity instances per scope.</b> A binding ref is unique within a scope, not across scopes, so two
 33/// scopes can legitimately declare the same one. Every binding gets its own freshly constructed activity, nothing is
 34/// cached or reused between scopes, and each activity is given a scope-qualified id. That is not a tidiness point:
 35/// <c>ActivityVisitor</c> collects activities into a set and skips one it has already seen, so a single instance
 36/// appearing under two scopes becomes one node in Elsa's identity graph and the second scope's child is missing from
 37/// it — a graph that builds, publishes, and then never runs half the process.
 38/// </para>
 39/// <para>
 40/// This binder lives in <c>Elsa.Bpmn.Interchange</c> rather than <c>Elsa.Bpmn</c> because <see cref="BpmnWorkBinding"/>
 41/// is a <c>Bpmn.Interchange</c> type: binding it in <c>Elsa.Bpmn</c> would pull the interchange library into the
 42/// execution module's dependency closure, which is exactly the package split D12 draws. The direction it needs is
 43/// available — <c>Elsa.Bpmn.Interchange</c> already references <c>Elsa.Bpmn</c>, so <see cref="BpmnProcess"/> and the
 44/// four activity targets are all in reach.
 45/// </para>
 46/// <para>
 47/// Root position is the one thing this binder does decide, and only for the one scope it directly returns:
 48/// <see cref="Bind"/> sets <see cref="BpmnProcess.IsRootScope"/> on that scope alone, because binding one process
 49/// definition on its own is what turns an imported <c>.bpmn</c> document into a workflow's own entry point. Every
 50/// scope <see cref="BindScope"/> produces for a nested process — a subprocess body, an event subprocess body —
 51/// leaves the flag off, so it stays safe to nest. Composing the scope <see cref="Bind"/> returns any deeper after
 52/// the fact — inside a <c>Flowchart</c>, inside another <c>BpmnProcess</c> — is not this binder's business to
 53/// notice: whoever does that composing is what decides whether root position still applies.
 54/// </para>
 55/// </remarks>
 5256public sealed class BpmnWorkBinder(BpmnActivityBindingFormat format)
 57{
 58    /// <summary>
 59    /// Binds one process definition, and every process nested inside it, into a <see cref="BpmnProcess"/> scope.
 60    /// </summary>
 61    /// <param name="definition">The process to bind.</param>
 62    /// <param name="bindings">Every binding the read produced, across all processes in the document.</param>
 63    /// <exception cref="BpmnBindingException">A binding cannot be turned into an activity.</exception>
 64    public BpmnProcess Bind(BpmnProcessDefinition definition, IReadOnlyCollection<BpmnWorkBinding> bindings)
 65    {
 3066        var scope = BindScope(definition, bindings);
 2567        scope.Id = definition.ProcessId;
 68
 69        // The one scope this call returns directly is what an import turns into a workflow's own root activity, so
 70        // it is the workflow's entry point by default. BindScope itself never sets this — see its own remarks —
 71        // because every OTHER scope it produces, for a nested process, is reached only through this recursion and
 72        // must stay off.
 2573        scope.IsRootScope = true;
 74
 2575        return scope;
 76    }
 77
 78    private BpmnProcess BindScope(BpmnProcessDefinition definition, IReadOnlyCollection<BpmnWorkBinding> bindings)
 79    {
 3380        var scope = new BpmnProcess
 3381        {
 3382            Process = definition
 3383        };
 84
 85        // A document-declared variable is invisible to the interpreter's IBpmnVariableReader port until it resolves
 86        // through Elsa's own ExpressionExecutionContext.GetVariable, which walks Container.Variables — not
 87        // BpmnProcessDefinition.Variables. Declaring one here for each is what makes a collection-mode multi-instance
 88        // over a document-declared collection readable instead of Absent. The declared default, when there is one,
 89        // travels as the JsonElement it already is: the reader serializes whatever the memory block holds, so nothing
 90        // here needs to interpret BpmnVariableDeclaration.TypeHint.
 6891        foreach (var declaration in definition.Variables)
 92        {
 193            scope.Variables.Add(new Variable(declaration.Name, declaration.DefaultValue is { } defaultValue ? (object)de
 94        }
 95
 96        // Element ids whose elsa:activityBinding was actually used. A declaration nothing consumed is refused below.
 3397        var consumed = new HashSet<string>(StringComparer.Ordinal);
 98
 18399        foreach (var binding in bindings.Where(binding => string.Equals(binding.ProcessId, definition.ProcessId, StringC
 100        {
 38101            var activity = CreateActivity(definition, binding, bindings, consumed);
 102
 103            // Scope-qualified and deterministic. The binding ref alone is unique only within its scope, and an id that
 104            // repeats across scopes gives two logical positions one identity in bookmarks and persisted state.
 34105            activity.Id = $"{binding.ProcessId}:{binding.BindingRef}";
 106
 34107            scope.Activities.Add(activity);
 34108            scope.WorkBindings[binding.BindingRef] = activity.Id;
 109        }
 110
 29111        RefuseUnusedDeclarations(definition, consumed);
 112
 28113        return scope;
 114    }
 115
 116    private IActivity CreateActivity(BpmnProcessDefinition definition, BpmnWorkBinding binding, IReadOnlyCollection<Bpmn
 38117        binding switch
 38118        {
 3119            BpmnWorkBinding.TimerWait timer => new Delay(IsoDurationOf(timer)),
 2120            BpmnWorkBinding.MessageWait message => new Event(message.MessageName),
 1121            BpmnWorkBinding.SignalWait signal => new Event(signal.SignalName),
 1122            BpmnWorkBinding.MessagePublish publish => new PublishEvent
 1123            {
 1124                EventName = new(publish.MessageName)
 1125            },
 2126            BpmnWorkBinding.CallProcess call => new DispatchWorkflow
 2127            {
 2128                WorkflowDefinitionId = new(CalledElementOf(call)),
 2129                WaitForCompletion = new(call.WaitForCompletion)
 2130            },
 3131            BpmnWorkBinding.NestedProcess nested => BindScope(nested.Definition, bindings),
 26132            BpmnWorkBinding.UnboundTask unbound => ReadDeclaredActivity(definition, unbound, consumed),
 38133            // The binding hierarchy is closed, so this is reachable only from a library version that added a kind this
 38134            // binder has never heard of. Skipping it would produce a scope whose interpreter starts work nothing maps.
 0135            _ => throw new BpmnBindingException($"The BPMN work binding kind '{binding.GetType().Name}' declared by elem
 38136        };
 137
 138    private IActivity ReadDeclaredActivity(BpmnProcessDefinition definition, BpmnWorkBinding.UnboundTask unbound, ISet<s
 139    {
 86140        var element = definition.Elements.FirstOrDefault(element => string.Equals(element.ElementId, unbound.ElementId, 
 141
 26142        if (BpmnActivityBindingFormat.Find(element?.Extensions) is not { } declaration)
 143        {
 2144            throw new BpmnBindingException(
 2145                $"BPMN element '{unbound.ElementId}' of process '{unbound.ProcessId}' is a '{unbound.TaskType}': the doc
 2146                + $"Declare one with an <{BpmnActivityBindingFormat.NamespacePrefix}:{BpmnActivityBindingFormat.BindingE
 147        }
 148
 24149        consumed.Add(unbound.ElementId);
 150
 24151        return format.Read(declaration);
 152    }
 153
 154    /// <summary>
 155    /// Refuses an <c>elsa:activityBinding</c> on an element that has no unbound task to bind.
 156    /// </summary>
 157    /// <remarks>
 158    /// Six of the seven kinds bind on their own and never consult a declaration, so one written on a timer, a
 159    /// subprocess or a gateway configures nothing. Ignoring it is the quiet answer: the author sees their expression in
 160    /// the file, the process runs, and the activity they configured never executes. Refusing says so.
 161    /// </remarks>
 162    private static void RefuseUnusedDeclarations(BpmnProcessDefinition definition, ISet<string> consumed)
 163    {
 123164        foreach (var element in definition.Elements.Where(element => BpmnActivityBindingFormat.Find(element.Extensions) 
 165        {
 1166            throw new BpmnBindingException(
 1167                $"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{definition.ProcessId}' carries
 1168                + "Only a task the document describes without implementing takes an authored activity binding.");
 169        }
 28170    }
 171
 172    private static TimeSpan IsoDurationOf(BpmnWorkBinding.TimerWait timer)
 173    {
 174        try
 175        {
 3176            return XmlConvert.ToTimeSpan(timer.IsoDuration);
 177        }
 1178        catch (Exception exception) when (exception is FormatException or OverflowException or ArgumentNullException)
 179        {
 1180            throw new BpmnBindingException($"BPMN element '{timer.ElementId}' declares the timer duration '{timer.IsoDur
 181        }
 2182    }
 183
 184    private static string CalledElementOf(BpmnWorkBinding.CallProcess call) =>
 2185        !string.IsNullOrWhiteSpace(call.CalledElement)
 2186            ? call.CalledElement
 2187            : throw new BpmnBindingException($"BPMN element '{call.ElementId}' is a call activity that names no calledEl
 188}