package org.wikimedia.integration import com.cloudbees.groovy.cps.NonCPS import org.codehaus.groovy.GroovyException import org.wikimedia.integration.ExecutionGraph /** * Provides execution context and value bindings to graph nodes. Each node's * context (see {@link ofNode()}) provides methods for saving its own values * and bindings for accessing values saved by ancestor nodes. * * These contexts can be used during graph stack evaluation to safely pass * values from ancestor nodes to descendent nodes, and to keep nodes from * different branches of execution to access each other's values. */ class ExecutionContext implements Serializable { private Map globals = [:] private ExecutionGraph graph ExecutionContext(executionGraph) { graph = executionGraph } /** * Create and return an execution context for the given node. */ NodeContext ofNode(node) { new NodeContext(node, graph.ancestorsOf(node)) } /** * Returns the names of all values bound by node contexts. */ List getAllKeys() { def keys = [] for (def ns in globals) { for (def key in globals[ns]) { keys.add("${ns}.${key}") } } keys } /** * Provides an execution context for a single given node that can resolve * bindings for values stored by ancestor nodes, set its own values, and * safely interpolate user-provided strings. * * @example * Given a graph: *

   *   def context = new ExecutionContext(new ExecutionGraph([
   *     ["a", "b", "c", "d", "e", "f"],
   *     ["x", "d", "y", "f"],
   *   ]))
   * 
* * @example * Values can be bound to any existing node. *

   *   context.ofNode("a")["foo"] = "afoo"
   *   context.ofNode("a")["bar"] = "abar"
   *   context.ofNode("b")["foo"] = "bfoo"
   *   context.ofNode("x")["bar"] = "xbar"
   * 
* * @example * Those same values can be accessed in contexts of descendent nodes, but * not in contexts of unrelated nodes. *

   *   assert context.ofNode("c").binding("a", "foo") == "afoo"
   *   assert context.ofNode("c").binding("a", "bar") == "abar"
   *   assert context.ofNode("c").binding("b", "foo") == "bfoo"
   *
   *   assert context.ofNode("c").binding("x", "bar") == null
   *
   *   assert context.ofNode("e").binding("a", "foo") == "afoo"
   *   assert context.ofNode("e").binding("a", "bar") == "abar"
   *   assert context.ofNode("e").binding("b", "foo") == "bfoo"
   *   assert context.ofNode("e").binding("x", "bar") == "xbar"
   * 
* * @example * Leveraging all of the above, user-provided configuration can be safely * interpolated. *

   *   assert (context.ofNode("c") % 'w-t-${a.foo} ${b.foo}') == "w-t-afoo bfoo"
   *   assert (context.ofNode("c") % 'w-t-${a.bar}') == "w-t-abar"
   *   assert (context.ofNode("x") % 'w-t-${x.bar}') == 'w-t-${x.bar}'
   * 
*/ class NodeContext implements Serializable { final VAR_EXPRESSION = /\$\{\w*\.\w+\}/ final def node final Set ancestors NodeContext(contextNode, contextAncestors) { globals[contextNode] = globals[contextNode] ?: [:] node = contextNode ancestors = contextAncestors } /** * Binds a value to a name in the globals store under the node's * namespace. The value may later be retrieved by any descendent node's * context using {@link binding()}. */ @NonCPS void bind(String key, value) throws NameAlreadyBoundException { if (globals[node].containsKey(key)) { throw new NameAlreadyBoundException(key: key) } globals[node][key] = value } /** * Retrieves a value previously bound using {@link bind()} to this node's * context. If the given key is not found under this node's namespace, a * {@link NameNotFoundException} is thrown. */ def binding(String key) throws NameNotFoundException { if (!globals[node].containsKey(key)) { throw new NameNotFoundException(ns: node, key: key) } globals[node][key] } /** * Retrieves a value previously bound using {@link bind()} under the given * ancestor node's namespace and name. */ def binding(ancestorNode, String key) throws NameNotFoundException, AncestorNotFoundException { if (!(ancestorNode in ancestors)) { throw new AncestorNotFoundException(ancestor: ancestorNode, node: node) } if (!globals[ancestorNode].containsKey(key)) { throw new NameNotFoundException(ns: ancestorNode, key: key) } globals[ancestorNode][key] } /** * Returns all objects bound to the given name under any node namespace, * as well as the node under which it is found. * * This should only be used at the end of an execution graph. */ Map getAll(String key) { globals.findAll { it.value[key] != null }.collectEntries { [it.key, it.value[key]] } } /** * Operator alias for {@link binding(String)} or, if a "namespace.key" is * given, {@link binding(def, String)}. */ def getAt(String key) { def keys = key.split(/\./) if (keys.size() > 1) { if (keys[0] == "") { return binding(keys[1]) } return binding(keys[0], keys[1]) } return binding(key) } /** * Interpolates the given string by substituting all symbol expressions * with values previously bound by ancestor nodes. */ String interpolate(String str) { // NOTE call to replaceAll does not rely on its sub matching feature as // Groovy CPS does not implement it correctly, and marking this method // as NonCPS causes it to only ever return the first substitution. str.replaceAll(VAR_EXPRESSION) { this[it[2..-2]] } } /** * Operator alias for {@link interpolate()}. */ String mod(String str) { interpolate(str) } /** * Operator alias for {@link bind()}. */ void putAt(String key, value) { bind(key, value) } } class AncestorNotFoundException extends GroovyException { def ancestor, node String getMessage() { "cannot access '${ancestor}.*' values since '${node}' does not follow it in the graph '${graph}'" } } class NameNotFoundException extends GroovyException { def ns, key String getMessage() { "no value bound for '${ns}.${key}'; all bound names are: ${getAllKeys().join(", ")}" } } class NameAlreadyBoundException extends GroovyException { def key String getMessage() { "'${node}' already has a value assigned to '${key}'" } } }