diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy
index 5ff63dcfaea..33e14ce22b7 100644
--- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy
+++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy
@@ -69,6 +69,7 @@ import com.netgrif.application.engine.objects.workflow.domain.menu.dashboard.Das
import com.netgrif.application.engine.objects.workflow.domain.menu.dashboard.DashboardManagementBody
import com.netgrif.application.engine.pdf.generator.config.PdfResourceConfigurationProperties
import com.netgrif.application.engine.pdf.generator.service.interfaces.IPdfGenerator
+import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.expando.DelegateExpando
import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService
import com.netgrif.application.engine.plugin.meta.PluginHolder
import com.netgrif.application.engine.startup.ImportHelper
@@ -107,7 +108,7 @@ import java.util.stream.Collectors
* ActionDelegate class contains Actions API methods.
*/
@SuppressWarnings(["GrMethodMayBeStatic", "GroovyUnusedDeclaration"])
-class ActionDelegate {
+class ActionDelegate extends DelegateExpando {
static final Logger log = LoggerFactory.getLogger(ActionDelegate)
@@ -249,6 +250,23 @@ class ActionDelegate {
this.Plugin = new PluginHolder()
}
+ void clearAfterExecution() {
+ this.action = null
+ this.useCase = null
+ this.task = null
+ this.actionsRunner = null
+
+ this.map?.clear()
+ this.map = null
+
+ this.outcomes?.clear()
+ this.outcomes = null
+
+ this.Frontend = null
+ this.NaeModule = null
+ this.Plugin = null
+ }
+
def initFieldsMap(Map fieldIds) {
fieldIds.each { name, id ->
set(name, fieldFactory.buildFieldWithoutValidation(useCase, id, null))
diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy
index 64b567ab4d1..cac53c6454e 100644
--- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy
+++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy
@@ -5,6 +5,7 @@ import com.netgrif.application.engine.business.orsr.IOrsrService
import com.netgrif.application.engine.importer.service.FieldFactory
import com.netgrif.application.engine.objects.event.events.event.ActionStartEvent
import com.netgrif.application.engine.objects.event.events.event.ActionStopEvent
+import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.expando.FunctionExpando
import com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService
import com.netgrif.application.engine.objects.petrinet.domain.Function
import com.netgrif.application.engine.objects.workflow.domain.Case
@@ -24,7 +25,7 @@ abstract class FieldActionsRunner {
private static final Logger log = LoggerFactory.getLogger(FieldActionsRunner.class)
@Lookup("actionDelegate")
- abstract ActionDelegate getActionDeleget()
+ abstract ActionDelegate getActionDelegate()
@Autowired
private IOrsrService orsrService
@@ -53,6 +54,7 @@ abstract class FieldActionsRunner {
log.debug("Action: $action")
def code = getActionCode(action, functions)
+ List outcomes
final ActionStartEvent actionStart = new ActionStartEvent(action)
try {
publisher.publishEvent(actionStart)
@@ -63,8 +65,11 @@ abstract class FieldActionsRunner {
log.error("Action: $action.definition")
publisher.publishEvent(new ActionStopEvent(action, actionStart, false))
throw e
+ } finally {
+ outcomes = new ArrayList<>(((ActionDelegate) code.delegate).outcomes)
+ cleanUp(code)
}
- return ((ActionDelegate) code.delegate).outcomes
+ return outcomes
}
Closure getActionCode(com.netgrif.application.engine.objects.petrinet.domain.dataset.logic.action.Action action, List functions, boolean shouldRewriteCachedActions = false) {
@@ -72,19 +77,19 @@ abstract class FieldActionsRunner {
}
Closure getActionCode(Closure code, List functions) {
- def actionDelegate = getActionDeleget()
+ def actionDelegate = getActionDelegate()
actionsCacheService.getCachedFunctions(functions).each {
- actionDelegate.metaClass."${it.function.name}" << it.code
+ actionDelegate."${it.function.name}" = it.code
}
actionsCacheService.getGlobalFunctionsCache().each { entry ->
- def namespace = [:]
+ def functionExpando = new FunctionExpando(actionDelegate)
entry.getValue().each {
- namespace["${it.function.name}"] = it.code.rehydrate(actionDelegate, it.code.owner, it.code.thisObject)
+ functionExpando."${it.function.name}" = it.code
}
- actionDelegate.metaClass."${entry.key}" = namespace
+ actionDelegate."${entry.key}" = functionExpando
}
- return code.rehydrate(actionDelegate, code.owner, code.thisObject)
+ return prepareCode(code, actionDelegate)
}
void addToCache(String key, Object value) {
@@ -107,4 +112,23 @@ abstract class FieldActionsRunner {
return orsrService
}
+ private void clearGroovyMetaClass(Object... targets) {
+ targets.each { target ->
+ if (target == null) {
+ return
+ }
+ GroovySystem.getMetaClassRegistry().removeMetaClass(target.getClass())
+ }
+ }
+
+ private Closure prepareCode(Closure closure, Object delegate) {
+ Closure hydratedClosure = closure.rehydrate(delegate, closure.owner, closure.thisObject)
+ hydratedClosure.setResolveStrategy(Closure.DELEGATE_FIRST)
+ return hydratedClosure
+ }
+
+ private void cleanUp(Closure code) {
+ ((ActionDelegate) code.delegate).clearAfterExecution()
+ clearGroovyMetaClass(code)
+ }
}
diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/DelegateExpando.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/DelegateExpando.groovy
new file mode 100644
index 00000000000..16f7d210f9a
--- /dev/null
+++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/DelegateExpando.groovy
@@ -0,0 +1,181 @@
+package com.netgrif.application.engine.petrinet.domain.dataset.logic.action.expando
+
+import com.netgrif.application.engine.utils.ClosureSignatureMatcher
+
+/**
+ * A specialized {@link Expando} implementation that enables dynamic method invocation with overloading support for process-scoped actions.
+ *
+ * This class is designed to attach and execute process-scoped actions to {@link com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate}
+ * in a proper Groovy-like manner. It extends the standard {@link Expando} behavior by intercepting method calls
+ * and executing them as closures with proper delegation settings.
+ *
+ *
+ * Unlike standard {@link Expando}, this implementation supports method overloading by storing multiple closures
+ * per method name in an internal function map. When a closure is set as a property, it is accumulated in a list
+ * associated with that property name, allowing multiple method signatures to coexist.
+ *
+ *
+ * When a method is called that doesn't exist on this object, the {@link DelegateExpando#methodMissing(String, Object)} method
+ * is invoked, which looks up the method name in the function map and searches for a matching closure based on
+ * parameter count and type compatibility. If a matching closure is found, it is rehydrated with this instance as
+ * the delegate, owner, and thisObject, configured with {@link Closure#DELEGATE_FIRST} resolution strategy, and executed.
+ *
+ *
+ * The closure rehydration process ensures that the closure executes in the context of this {@link DelegateExpando}
+ * instance, allowing it to access properties and methods defined on this object while maintaining proper delegation
+ * semantics for the ActionDelegate pattern.
+ *
+ *
+ * Usage Example:
+ *
{@code
+ * def expando = new DelegateExpando()
+ * expando.greet = { String name -> "Hello, ${name}!" }
+ * expando.greet = { String name, String title -> "Hello, ${title} ${name}!" }
+ *
+ * expando.greet("John") // Returns "Hello, John!"
+ * expando.greet("Smith", "Dr.") // Returns "Hello, Dr. Smith!"
+ *}
+ *
+ *
+ * This class is primarily used by {@link FieldActionsRunner} to dynamically attach process-scoped functions
+ * to action delegates, enabling flexible and context-aware execution of workflow actions.
+ *
+ *
+ * @see Expando* @see com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate* @see FieldActionsRunner
+ */
+class DelegateExpando extends Expando {
+
+ private static final String DO_CALL = "doCall"
+
+ /**
+ * Internal storage for closures mapped by method name.
+ *
+ * Each method name can map to multiple closures, enabling method overloading based on parameter types.
+ * The list maintains insertion order to preserve the sequence in which overloaded methods are defined.
+ *
+ */
+ private final Map> functionMap = new LinkedHashMap<>()
+
+ /**
+ * Returns the delegate instance for closure execution.
+ *
+ * This method provides access to the delegate object that will be set on cloned closures
+ * during {@link #methodMissing(String, Object)} execution. By default, it returns this
+ * instance, but can be overridden in subclasses to provide a different delegation target.
+ *
+ *
+ * The returned delegate is used as the delegate, owner, and thisObject for all dynamically
+ * invoked closures, ensuring proper scope resolution within the ActionDelegate pattern.
+ *
+ *
+ * @return the delegate instance to be used for closure execution (this instance by default)
+ */
+ DelegateExpando getDelegate() {
+ return this
+ }
+
+ /**
+ * Overrides property setting to accumulate closures in the function map for method overloading support.
+ *
+ * When a closure is assigned to a property, it is added to the list of closures associated with that
+ * property name in the function map, rather than replacing any existing closure. This accumulation
+ * mechanism is the foundation for method overloading: each closure represents a different method
+ * signature, and all are stored under the same method name.
+ *
+ *
+ * The insertion order is preserved using a {@link LinkedHashMap}, ensuring that when multiple closures
+ * match during method resolution, the first matching closure (earliest defined) is selected.
+ *
+ *
+ * Non-closure values are delegated to the parent {@link Expando} implementation, which stores them
+ * as regular dynamic properties accessible via standard property access syntax.
+ *
+ *
+ * @param name the property name; becomes the method name when value is a closure
+ * @param value the value to set; if a {@link Closure}, it is accumulated in the function map;
+ * otherwise handled by parent Expando
+ */
+ void setProperty(String name, Object value) {
+ if (value instanceof Closure) {
+ functionMap.computeIfAbsent(name, { new ArrayList<>()}) << value
+ } else {
+ super.setProperty(name, value)
+ }
+ }
+
+ /**
+ * Intercepts method calls that don't exist on this object and attempts to execute matching closures.
+ *
+ * This method implements the dynamic method invocation and overload resolution mechanism by:
+ *
+ * - Normalizing the incoming arguments to a standard Object[] array via {@link #normalize(Object)}
+ * - Looking up candidate closures in the function map by method name
+ * - Iterating through candidates to find the first closure with matching parameter count and compatible types
+ * - Cloning the matching closure to prevent modification of the original
+ * - Rehydrating the clone with the result of {@link #getDelegate()} as its delegate
+ * - Setting the closure's resolution strategy to {@link Closure#DELEGATE_FIRST} for proper scope resolution
+ * - Executing the closure with the normalized arguments
+ *
+ *
+ *
+ * Parameter matching is delegated to {@link ClosureSignatureMatcher#matches(Closure, Object [ ])}, which verifies
+ * that the argument count matches and each argument type is assignable to the corresponding parameter type.
+ * Null arguments are treated as compatible with any reference parameter type. The first matching closure in
+ * insertion order is selected and executed.
+ *
+ *
+ * Closure Isolation: Each method invocation receives a clone of the matched closure, ensuring that
+ * modifications to delegate, resolveStrategy, or other closure properties don't affect subsequent calls.
+ *
+ *
+ * @param name the name of the method being called
+ * @param args the arguments passed to the method; can be an Object[], List, or single value
+ * @return the result of executing the matched closure
+ * @throws MissingMethodException if no closure with the given name exists or no closure matches the argument signature
+ */
+ def methodMissing(String name, args) {
+ Object[] normalizedArgs = normalize(args)
+
+ def candidates = functionMap[name]
+ if (candidates) {
+ Closure match = candidates.find { fn ->
+ ClosureSignatureMatcher.matches(fn, normalizedArgs)
+ }
+ if (match) {
+ def instance = match.clone()
+ ((Closure) instance).delegate = getDelegate()
+ ((Closure) instance).resolveStrategy = Closure.DELEGATE_FIRST
+ return ((Closure) instance).call(*normalizedArgs)
+ }
+ }
+ throw new MissingMethodException(name, this.class, args as Object[])
+ }
+
+ /**
+ * Normalizes method arguments into a consistent Object[] array format.
+ *
+ * Groovy's method invocation can pass arguments in various forms depending on the call site.
+ * This method ensures that arguments are always converted to an Object[] array for consistent
+ * processing by {@link #methodMissing(String, Object)}.
+ *
+ *
+ * Normalization rules:
+ *
+ * - If args is already an Object[], it is returned as-is
+ * - If args is a List, it is converted to an array via {@link List#toArray()}
+ * - Otherwise, args is wrapped in a single-element Object[] array
+ *
+ *
+ *
+ * @param args the raw arguments passed from Groovy's method invocation mechanism
+ * @return a normalized Object[] array containing the method arguments
+ */
+ private Object[] normalize(args) {
+ if (args instanceof Object[]) {
+ return args
+ } else if (args instanceof List) {
+ return args.toArray()
+ }
+ return [args] as Object[]
+ }
+}
diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/FunctionExpando.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/FunctionExpando.groovy
new file mode 100644
index 00000000000..9291ffe4e69
--- /dev/null
+++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/FunctionExpando.groovy
@@ -0,0 +1,124 @@
+package com.netgrif.application.engine.petrinet.domain.dataset.logic.action.expando
+
+import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate
+import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.FieldActionsRunner
+import com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService
+
+/**
+ * A specialized {@link DelegateExpando} implementation that provides memory-efficient organization of global scoped
+ * functions from business processes by namespace.
+ *
+ * This class serves as a namespace container for global functions defined in business processes. It extends
+ * {@link DelegateExpando} to inherit property-based function storage capabilities while adding delegation
+ * to a parent {@link ActionDelegate} for non-function method calls.
+ *
+ *
+ * Key characteristics:
+ *
+ * - Stores function closures as properties (inherited from {@link DelegateExpando})
+ * - Provides namespace-based organization of global functions (e.g., process-specific function scopes)
+ * - Delegates unresolved method calls to the parent {@link ActionDelegate}
+ * - Enables clean separation of global functions by scope while maintaining access to {@link ActionDelegate} context
+ *
+ *
+ *
+ * Typical usage in {@link FieldActionsRunner}:
+ *
+ * - Global functions are cached as closures in {@link IFieldActionsCacheService}, grouped by namespace
+ * - During action execution, a {@code FunctionExpando} instance is created for each namespace of global functions
+ * - Function closures are attached to the {@code FunctionExpando} as properties (e.g., {@code functionExpando.myFunc = closure})
+ * - The {@code FunctionExpando} instance is attached to the {@link ActionDelegate} (e.g., {@code actionDelegate.namespace = functionExpando})
+ * - Functions can be invoked via namespace (e.g., {@code namespace.myFunc()} in action code)
+ * - {@link DelegateExpando}'s {@code methodMissing} handles function invocation by rehydrating closures with {@link ActionDelegate} context
+ * - This class's {@code methodMissing} handles fallback to {@link ActionDelegate} for non-function calls
+ *
+ *
+ *
+ * This design enables logical grouping of global functions while ensuring they have full access to the
+ * {@link ActionDelegate} execution environment.
+ *
+ *
+ * @see ActionDelegate* @see DelegateExpando* @see IFieldActionsCacheService* @see FieldActionsRunner
+ */
+class FunctionExpando extends DelegateExpando {
+
+ /**
+ * Reference to the parent {@link ActionDelegate} that provides the execution context for actions.
+ *
+ * This delegate is used for:
+ *
+ * - Fallback delegation of method calls that are not stored as function closures
+ * - Providing context to function closures through {@link DelegateExpando}'s rehydration mechanism
+ * - Enabling functions to access case data, task data, and other {@link ActionDelegate} capabilities
+ *
+ *
+ *
+ * Note: Property access is handled by the parent {@link DelegateExpando} class, not by direct delegation
+ * to this field.
+ *
+ */
+ ActionDelegate parentDelegate
+
+ /**
+ * Constructs a new {@code FunctionExpando} associated with the specified {@link ActionDelegate}.
+ *
+ * The constructor establishes the delegation relationship that enables this {@code FunctionExpando}
+ * to fall back to the {@link ActionDelegate} for method calls that are not resolved as function closures.
+ *
+ *
+ * @param parentDelegate the {@link ActionDelegate} that provides the execution context for functions
+ * and handles non-function method calls
+ */
+ FunctionExpando(ActionDelegate parentDelegate) {
+ this.parentDelegate = parentDelegate
+ }
+
+ /**
+ * Returns the parent delegate that provides the execution context.
+ *
+ * This method is overridden from {@link DelegateExpando} to return the {@link ActionDelegate}
+ * instance, enabling proper delegation and context provision for function closures.
+ *
+ *
+ * @return the parent {@link ActionDelegate} instance
+ */
+ @Override
+ DelegateExpando getDelegate() {
+ return parentDelegate
+ }
+
+ /**
+ * Intercepts method calls to provide two-level dynamic resolution: functions first, then {@link ActionDelegate}.
+ *
+ * This method implements Groovy's dynamic method resolution mechanism to handle calls
+ * that are not statically defined on this class. It follows this resolution strategy:
+ *
+ * - Delegate to parent {@link DelegateExpando}'s {@code methodMissing} to check for function closures stored as properties
+ * - {@link DelegateExpando} rehydrates found closures with {@code parentDelegate} as context and invokes them
+ * - If no function closure is found ({@code MissingMethodException}), fall back to {@code parentDelegate.invokeMethod()}
+ * - If neither resolution succeeds, propagate the {@code MissingMethodException}
+ *
+ *
+ *
+ * This two-level approach enables:
+ *
+ * - Execution of namespaced global functions with full {@link ActionDelegate} context
+ * - Access to case and task data from within function closures (via {@link DelegateExpando}'s rehydration)
+ * - Transparent fallback to {@link ActionDelegate} methods when no function matches
+ * - Clean separation of namespace-scoped functions from {@link ActionDelegate} methods
+ *
+ *
+ *
+ * @param name the name of the method being invoked
+ * @param args the arguments passed to the method (typically an {@code Object} array)
+ * @return the result of the function or method invocation
+ * @throws groovy.lang.MissingMethodException if neither a function closure nor a delegate method exists
+ */
+ def methodMissing(String name, args) {
+ try {
+ return super.methodMissing(name, args) // try own functions first
+ } catch (MissingMethodException ignored) {
+ return parentDelegate.invokeMethod(name, args) // fallback to delegate
+ }
+ }
+}
diff --git a/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java b/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java
index 9a392c16ed8..db733bf9ded 100644
--- a/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java
+++ b/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java
@@ -19,16 +19,21 @@ public class GroovyShellFactory implements IGroovyShellFactory {
@Autowired
private CompilerConfiguration configuration;
+ private GroovyShell shell;
+
@Override
public GroovyShell getGroovyShell() {
- ImportCustomizer importCustomizer = new ImportCustomizer();
+ if (shell == null) {
+ ImportCustomizer importCustomizer = new ImportCustomizer();
- Set classNames = findAllClassesUsingClassLoader("com.netgrif.application.engine.workflow.domain");
- importCustomizer.addImports(classNames.toArray(new String[0]));
+ Set classNames = findAllClassesUsingClassLoader("com.netgrif.application.engine.workflow.domain");
+ importCustomizer.addImports(classNames.toArray(new String[0]));
- configuration.addCompilationCustomizers(importCustomizer);
+ configuration.addCompilationCustomizers(importCustomizer);
- return new GroovyShell(this.getClass().getClassLoader(), new groovy.lang.Binding(), this.configuration);
+ shell = new GroovyShell(this.getClass().getClassLoader(), new groovy.lang.Binding(), this.configuration);
+ }
+ return shell;
}
private Set findAllClassesUsingClassLoader(String packageName) {
diff --git a/application-engine/src/main/java/com/netgrif/application/engine/utils/ClosureSignatureMatcher.java b/application-engine/src/main/java/com/netgrif/application/engine/utils/ClosureSignatureMatcher.java
new file mode 100644
index 00000000000..1b065c904b0
--- /dev/null
+++ b/application-engine/src/main/java/com/netgrif/application/engine/utils/ClosureSignatureMatcher.java
@@ -0,0 +1,59 @@
+package com.netgrif.application.engine.utils;
+
+import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.FieldActionsRunner;
+import groovy.lang.Closure;
+
+import java.lang.reflect.Method;
+
+/**
+ * Utility class for matching Groovy Closure signatures against provided arguments.
+ *
+ * This class provides functionality to determine whether a given Groovy {@link Closure} can accept
+ * a specific set of arguments based on its method signature. It inspects the closure's {@code doCall}
+ * methods and verifies if any of them match the provided argument types and count.
+ *
+ *
+ * This is particularly useful when working with dynamic Groovy closures in a Java context,
+ * where compile-time type checking is not available and runtime signature matching is required.
+ *
+ *
+ * @see Closure
+ * @see FieldActionsRunner
+ */
+public class ClosureSignatureMatcher {
+
+ /**
+ * Checks whether the provided Groovy closure can accept the given arguments.
+ *
+ * This method iterates through all methods of the closure's class, looking for methods named {@code doCall}.
+ * For each {@code doCall} method found, it verifies:
+ *
+ * - The parameter count matches the argument count
+ * - Each argument's type is assignable to the corresponding parameter type
+ * - Null arguments are allowed for any parameter type
+ *
+ *
+ *
+ * @param fn the Groovy closure to check for signature compatibility
+ * @param args the array of arguments to match against the closure's signature;
+ * null values in the array are considered compatible with any parameter type
+ * @return {@code true} if at least one {@code doCall} method in the closure matches the provided arguments;
+ * {@code false} otherwise
+ */
+ public static boolean matches(Closure> fn, Object[] args) {
+ for (Method method : fn.getClass().getMethods()) {
+ if (!method.getName().equals("doCall")) continue;
+ Class>[] params = method.getParameterTypes();
+ if (params.length != args.length) continue;
+ boolean allMatch = true;
+ for (int i = 0; i < params.length; i++) {
+ if (args[i] != null && !params[i].isAssignableFrom(args[i].getClass())) {
+ allMatch = false;
+ break;
+ }
+ }
+ if (allMatch) return true;
+ }
+ return false;
+ }
+}