azkaban-developers

Details

diff --git a/src/main/java/azkaban/utils/PropsUtils.java b/src/main/java/azkaban/utils/PropsUtils.java
index 37cd003..2f826a3 100644
--- a/src/main/java/azkaban/utils/PropsUtils.java
+++ b/src/main/java/azkaban/utils/PropsUtils.java
@@ -31,6 +31,10 @@ import java.util.regex.Pattern;
 import azkaban.executor.ExecutableFlowBase;
 import azkaban.flow.CommonJobProperties;
 
+import org.apache.commons.jexl2.Expression;
+import org.apache.commons.jexl2.JexlEngine;
+import org.apache.commons.jexl2.JexlException;
+import org.apache.commons.jexl2.MapContext;
 import org.apache.commons.lang.StringUtils;
 import org.joda.time.DateTime;
 
@@ -144,7 +148,7 @@ public class PropsUtils {
 		return false;
 	}
 
-	private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\{([a-zA-Z_.0-9]+)\\}");
+	private static final Pattern VARIABLE_REPLACEMENT_PATTERN = Pattern.compile("\\$\\{([a-zA-Z_.0-9]+)\\}");
 
 	public static Props resolveProps(Props props) {
 		if (props == null) return null;
@@ -162,6 +166,12 @@ public class PropsUtils {
 			resolvedProps.put(key, replacedValue);
 		}
 		
+		for (String key : resolvedProps.getKeySet()) {
+			String value = resolvedProps.get(key);
+			String expressedValue = resolveVariableExpression(value);
+			resolvedProps.put(key, expressedValue);
+		}
+		
 		return resolvedProps;
 	};
 	
@@ -169,7 +179,7 @@ public class PropsUtils {
 		StringBuffer buffer = new StringBuffer();
 		int startIndex = 0;
 		
-		Matcher matcher = VARIABLE_PATTERN.matcher(value);
+		Matcher matcher = VARIABLE_REPLACEMENT_PATTERN.matcher(value);
 		while (matcher.find(startIndex)) {
 			if (startIndex < matcher.start()) {
 				// Copy everything up front to the buffer
@@ -208,6 +218,60 @@ public class PropsUtils {
 		return buffer.toString();
 	}
 	
+	private static String resolveVariableExpression(String value) {
+		JexlEngine jexl = new JexlEngine();
+		return resolveVariableExpression(value, value.length(), jexl);
+	}
+	
+	/**
+	 * Function that looks for expressions to parse.
+	 * It parses backwards to capture embedded expressions
+	 * 
+	 * @param value
+	 * @param last
+	 * @param jexl
+	 * @return
+	 */
+	private static String resolveVariableExpression(String value, int last, JexlEngine jexl) {
+		int lastIndex = value.lastIndexOf("$(", last);
+		if (lastIndex == -1) {
+			return value;
+		}
+		
+		// Want to check that everything is well formed, and that
+		// we properly capture $( ...(...)...).
+		int bracketCount = 0;
+		int nextClosed = lastIndex + 2;
+		for (; nextClosed < value.length(); ++nextClosed) {
+			if (value.charAt(nextClosed) == '(') {
+				bracketCount++;
+			}
+			else if (value.charAt(nextClosed) == ')') {
+				bracketCount--;
+				if (bracketCount == -1) {
+					break;
+				}
+			}
+		}
+		
+		if (nextClosed == value.length()) {
+			throw new IllegalArgumentException("Expression " + value + " not well formed.");
+		}
+		
+		String innerExpression = value.substring(lastIndex + 2, nextClosed);
+		Object result = null;
+		try {
+			Expression e = jexl.createExpression(innerExpression);
+			result = e.evaluate(new MapContext());
+		}
+		catch (JexlException e) {
+			throw new IllegalArgumentException("Expression " + value + " not well formed. " + e.getMessage(), e);
+		}
+		
+		String newValue = value.substring(0, lastIndex) + result.toString() + value.substring(nextClosed + 1);
+		return resolveVariableExpression(newValue, lastIndex, jexl);
+	}
+	
 	public static Props addCommonFlowProperties(Props parentProps, final ExecutableFlowBase flow) {
 		Props props = new Props(parentProps);
 
diff --git a/unit/java/azkaban/test/utils/PropsUtilsTest.java b/unit/java/azkaban/test/utils/PropsUtilsTest.java
index 09edb89..6b34b48 100644
--- a/unit/java/azkaban/test/utils/PropsUtilsTest.java
+++ b/unit/java/azkaban/test/utils/PropsUtilsTest.java
@@ -51,6 +51,64 @@ public class PropsUtilsTest {
 	}
 	
 	@Test
+	public void testExpressionResolution() throws IOException {
+		Props props = Props.of(
+			"normkey", "normal",
+			"num1", "1",
+			"num2", "2",
+			"num3", "3",
+			"variablereplaced", "${num1}",
+			"expression1", "$(1+10)",
+			"expression2", "$(1+10)*2",
+			"expression3", "$($(${num1} + ${num3})*10)",
+			"expression4", "$(${num1} + ${expression3})",
+			"expression5", "$($($(2+3)) + 3) + $(${expression3} + 1)",
+			"expression6", "$(1 + ${normkey})",
+			"expression7", "$(\"${normkey}\" + 1)",
+			"expression8", "${expression1}",
+			"expression9", "$((2+3) + 3)"
+		);
+
+		Props resolved = PropsUtils.resolveProps(props);
+		Assert.assertEquals("normal", resolved.get("normkey"));
+		Assert.assertEquals("1", resolved.get("num1"));
+		Assert.assertEquals("2", resolved.get("num2"));
+		Assert.assertEquals("3", resolved.get("num3"));
+		Assert.assertEquals("1", resolved.get("variablereplaced"));
+		Assert.assertEquals("11", resolved.get("expression1"));
+		Assert.assertEquals("11*2", resolved.get("expression2"));
+		Assert.assertEquals("40", resolved.get("expression3"));
+		Assert.assertEquals("41", resolved.get("expression4"));
+		Assert.assertEquals("8 + 41", resolved.get("expression5"));
+		Assert.assertEquals("1", resolved.get("expression6"));
+		Assert.assertEquals("normal1", resolved.get("expression7"));
+		Assert.assertEquals("11", resolved.get("expression8"));
+		Assert.assertEquals("8", resolved.get("expression9"));
+	}
+	
+	@Test
+	public void testMalformedExpressionProps() throws IOException {
+		// unclosed
+		Props props = Props.of("key", "$(1+2");
+		failIfNotException(props);
+		
+		props = Props.of("key", "$((1+2)");
+		failIfNotException(props);
+		
+		// bad variable replacement
+		props = Props.of("key", "$(${dontexist}+2)");
+		failIfNotException(props);
+	
+		// bad expression
+		props = Props.of("key", "$(2 +)");
+		failIfNotException(props);
+		
+		// bad expression
+		props = Props.of("key", "$(2 + #hello)");
+		failIfNotException(props);
+	}
+	
+	@Test
 	public void testCyclesResolveProps() throws IOException {
 		Props propsGrandParent = new Props();
 		Props propsParent = new Props(propsGrandParent);