| | | 1 | | using System.Xml; |
| | | 2 | | using Bpmn.Interchange; |
| | | 3 | | using Bpmn.Model; |
| | | 4 | | using Elsa.Bpmn.Activities; |
| | | 5 | | using Elsa.Bpmn.Interchange.Exceptions; |
| | | 6 | | using Elsa.Scheduling.Activities; |
| | | 7 | | using Elsa.Workflows; |
| | | 8 | | using Elsa.Workflows.Memory; |
| | | 9 | | using Elsa.Workflows.Runtime.Activities; |
| | | 10 | | |
| | | 11 | | namespace 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> |
| | 52 | 56 | | public 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 | | { |
| | 30 | 66 | | var scope = BindScope(definition, bindings); |
| | 25 | 67 | | 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. |
| | 25 | 73 | | scope.IsRootScope = true; |
| | | 74 | | |
| | 25 | 75 | | return scope; |
| | | 76 | | } |
| | | 77 | | |
| | | 78 | | private BpmnProcess BindScope(BpmnProcessDefinition definition, IReadOnlyCollection<BpmnWorkBinding> bindings) |
| | | 79 | | { |
| | 33 | 80 | | var scope = new BpmnProcess |
| | 33 | 81 | | { |
| | 33 | 82 | | Process = definition |
| | 33 | 83 | | }; |
| | | 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. |
| | 68 | 91 | | foreach (var declaration in definition.Variables) |
| | | 92 | | { |
| | 1 | 93 | | 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. |
| | 33 | 97 | | var consumed = new HashSet<string>(StringComparer.Ordinal); |
| | | 98 | | |
| | 183 | 99 | | foreach (var binding in bindings.Where(binding => string.Equals(binding.ProcessId, definition.ProcessId, StringC |
| | | 100 | | { |
| | 38 | 101 | | 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. |
| | 34 | 105 | | activity.Id = $"{binding.ProcessId}:{binding.BindingRef}"; |
| | | 106 | | |
| | 34 | 107 | | scope.Activities.Add(activity); |
| | 34 | 108 | | scope.WorkBindings[binding.BindingRef] = activity.Id; |
| | | 109 | | } |
| | | 110 | | |
| | 29 | 111 | | RefuseUnusedDeclarations(definition, consumed); |
| | | 112 | | |
| | 28 | 113 | | return scope; |
| | | 114 | | } |
| | | 115 | | |
| | | 116 | | private IActivity CreateActivity(BpmnProcessDefinition definition, BpmnWorkBinding binding, IReadOnlyCollection<Bpmn |
| | 38 | 117 | | binding switch |
| | 38 | 118 | | { |
| | 3 | 119 | | BpmnWorkBinding.TimerWait timer => new Delay(IsoDurationOf(timer)), |
| | 2 | 120 | | BpmnWorkBinding.MessageWait message => new Event(message.MessageName), |
| | 1 | 121 | | BpmnWorkBinding.SignalWait signal => new Event(signal.SignalName), |
| | 1 | 122 | | BpmnWorkBinding.MessagePublish publish => new PublishEvent |
| | 1 | 123 | | { |
| | 1 | 124 | | EventName = new(publish.MessageName) |
| | 1 | 125 | | }, |
| | 2 | 126 | | BpmnWorkBinding.CallProcess call => new DispatchWorkflow |
| | 2 | 127 | | { |
| | 2 | 128 | | WorkflowDefinitionId = new(CalledElementOf(call)), |
| | 2 | 129 | | WaitForCompletion = new(call.WaitForCompletion) |
| | 2 | 130 | | }, |
| | 3 | 131 | | BpmnWorkBinding.NestedProcess nested => BindScope(nested.Definition, bindings), |
| | 26 | 132 | | BpmnWorkBinding.UnboundTask unbound => ReadDeclaredActivity(definition, unbound, consumed), |
| | 38 | 133 | | // The binding hierarchy is closed, so this is reachable only from a library version that added a kind this |
| | 38 | 134 | | // binder has never heard of. Skipping it would produce a scope whose interpreter starts work nothing maps. |
| | 0 | 135 | | _ => throw new BpmnBindingException($"The BPMN work binding kind '{binding.GetType().Name}' declared by elem |
| | 38 | 136 | | }; |
| | | 137 | | |
| | | 138 | | private IActivity ReadDeclaredActivity(BpmnProcessDefinition definition, BpmnWorkBinding.UnboundTask unbound, ISet<s |
| | | 139 | | { |
| | 86 | 140 | | var element = definition.Elements.FirstOrDefault(element => string.Equals(element.ElementId, unbound.ElementId, |
| | | 141 | | |
| | 26 | 142 | | if (BpmnActivityBindingFormat.Find(element?.Extensions) is not { } declaration) |
| | | 143 | | { |
| | 2 | 144 | | throw new BpmnBindingException( |
| | 2 | 145 | | $"BPMN element '{unbound.ElementId}' of process '{unbound.ProcessId}' is a '{unbound.TaskType}': the doc |
| | 2 | 146 | | + $"Declare one with an <{BpmnActivityBindingFormat.NamespacePrefix}:{BpmnActivityBindingFormat.BindingE |
| | | 147 | | } |
| | | 148 | | |
| | 24 | 149 | | consumed.Add(unbound.ElementId); |
| | | 150 | | |
| | 24 | 151 | | 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 | | { |
| | 123 | 164 | | foreach (var element in definition.Elements.Where(element => BpmnActivityBindingFormat.Find(element.Extensions) |
| | | 165 | | { |
| | 1 | 166 | | throw new BpmnBindingException( |
| | 1 | 167 | | $"BPMN element '{element.ElementId}' ({element.ElementType}) of process '{definition.ProcessId}' carries |
| | 1 | 168 | | + "Only a task the document describes without implementing takes an authored activity binding."); |
| | | 169 | | } |
| | 28 | 170 | | } |
| | | 171 | | |
| | | 172 | | private static TimeSpan IsoDurationOf(BpmnWorkBinding.TimerWait timer) |
| | | 173 | | { |
| | | 174 | | try |
| | | 175 | | { |
| | 3 | 176 | | return XmlConvert.ToTimeSpan(timer.IsoDuration); |
| | | 177 | | } |
| | 1 | 178 | | catch (Exception exception) when (exception is FormatException or OverflowException or ArgumentNullException) |
| | | 179 | | { |
| | 1 | 180 | | throw new BpmnBindingException($"BPMN element '{timer.ElementId}' declares the timer duration '{timer.IsoDur |
| | | 181 | | } |
| | 2 | 182 | | } |
| | | 183 | | |
| | | 184 | | private static string CalledElementOf(BpmnWorkBinding.CallProcess call) => |
| | 2 | 185 | | !string.IsNullOrWhiteSpace(call.CalledElement) |
| | 2 | 186 | | ? call.CalledElement |
| | 2 | 187 | | : throw new BpmnBindingException($"BPMN element '{call.ElementId}' is a call activity that names no calledEl |
| | | 188 | | } |