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: + *

    + *
  1. Normalizing the incoming arguments to a standard Object[] array via {@link #normalize(Object)}
  2. + *
  3. Looking up candidate closures in the function map by method name
  4. + *
  5. Iterating through candidates to find the first closure with matching parameter count and compatible types
  6. + *
  7. Cloning the matching closure to prevent modification of the original
  8. + *
  9. Rehydrating the clone with the result of {@link #getDelegate()} as its delegate
  10. + *
  11. Setting the closure's resolution strategy to {@link Closure#DELEGATE_FIRST} for proper scope resolution
  12. + *
  13. Executing the closure with the normalized arguments
  14. + *
+ *

+ *

+ * 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}: + *

    + *
  1. Global functions are cached as closures in {@link IFieldActionsCacheService}, grouped by namespace
  2. + *
  3. During action execution, a {@code FunctionExpando} instance is created for each namespace of global functions
  4. + *
  5. Function closures are attached to the {@code FunctionExpando} as properties (e.g., {@code functionExpando.myFunc = closure})
  6. + *
  7. The {@code FunctionExpando} instance is attached to the {@link ActionDelegate} (e.g., {@code actionDelegate.namespace = functionExpando})
  8. + *
  9. Functions can be invoked via namespace (e.g., {@code namespace.myFunc()} in action code)
  10. + *
  11. {@link DelegateExpando}'s {@code methodMissing} handles function invocation by rehydrating closures with {@link ActionDelegate} context
  12. + *
  13. This class's {@code methodMissing} handles fallback to {@link ActionDelegate} for non-function calls
  14. + *
+ *

+ *

+ * 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: + *

    + *
  1. Delegate to parent {@link DelegateExpando}'s {@code methodMissing} to check for function closures stored as properties
  2. + *
  3. {@link DelegateExpando} rehydrates found closures with {@code parentDelegate} as context and invokes them
  4. + *
  5. If no function closure is found ({@code MissingMethodException}), fall back to {@code parentDelegate.invokeMethod()}
  6. + *
  7. If neither resolution succeeds, propagate the {@code MissingMethodException}
  8. + *
+ *

+ *

+ * 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; + } +}