Fix #243 - Add "Elvis" and "Null Coalescing" operators
diff --git a/spec/src/main/asciidoc/ELSpec.adoc b/spec/src/main/asciidoc/ELSpec.adoc
index 642ce81..972152e 100644
--- a/spec/src/main/asciidoc/ELSpec.adoc
+++ b/spec/src/main/asciidoc/ELSpec.adoc
@@ -1039,6 +1039,28 @@
* If `A` is `false`, evaluate and return `C`
+== Elvis Operator - `A ?: B`
+
+The Elivs operator returns the first operand if that operand is `true`.
+Otherwise, the second operand is returned.
+
+Coerce `A` to `boolean` (primitive):
+
+* If `A` is `true`, evaluate and return `A` (without coercion)
+
+* If `A` is `false`, evaluate and return `B`
+
+== Null coalescing Operator - `A ?? B`
+
+The Null coalescing operator returns the first operand if that operand is non-null.
+Otherwise, the second operand is returned.
+
+To evaluate `A ?? B`:
+
+* If `A` is `null`, return `B`
+
+* Otherwise return `A`
+
=== Assignment Operator - `A = B`
Assign the value of `B` to `A`. `A` must be an _lvalue_, otherwise, a
@@ -1703,7 +1725,7 @@
void Assignment() : {}
{
LOOKAHEAD(4) LambdaExpression() |
- Choice() ( LOOKAHEAD(2) <ASSIGN> Assignment() #Assign(2) )*
+ Ternary() ( LOOKAHEAD(2) <ASSIGN> Assignment() #Assign(2) )*
}
/*
@@ -1712,7 +1734,7 @@
void LambdaExpression() #LambdaExpression : {}
{
LambdaParameters() <ARROW>
- (LOOKAHEAD(3) LambdaExpression() | Choice() )
+ (LOOKAHEAD(3) LambdaExpression() | Ternary() )
}
void LambdaParameters() #LambdaParameters: {}
@@ -1724,12 +1746,19 @@
}
/*
- * Choice
- * For Choice markup a ? b : c, right associative
+ * Ternary
+ * For '??' '?:' '? :' then Or
*/
-void Choice() : {}
+void Ternary() : {}
{
- Or() (<QUESTIONMARK> Choice() <COLON> Choice() #Choice(3))?
+ Or()
+ (
+ LOOKAHEAD(2) (<QUESTIONMARK><QUESTIONMARK> Ternary() #NullCoalescing(2))
+ |
+ LOOKAHEAD(2) (<QUESTIONMARK><COLON> Ternary() #Elvis(2))
+ |
+ (<QUESTIONMARK> Ternary() <COLON> Ternary() #Choice(3))
+ )?
}
/*
@@ -1900,7 +1929,7 @@
{
<LPAREN>
LambdaParameters() <ARROW>
- (LOOKAHEAD(3) LambdaExpression() | Choice() )
+ (LOOKAHEAD(3) LambdaExpression() | Ternary() )
<RPAREN>
(MethodArguments())*
}
diff --git a/tck/src/main/java/com/sun/ts/tests/el/common/util/ExprEval.java b/tck/src/main/java/com/sun/ts/tests/el/common/util/ExprEval.java
index 8171d9a..8bfb5aa 100644
--- a/tck/src/main/java/com/sun/ts/tests/el/common/util/ExprEval.java
+++ b/tck/src/main/java/com/sun/ts/tests/el/common/util/ExprEval.java
@@ -65,6 +65,10 @@
sandwich = "{empty A}";
else if ("conditional".equals(operation))
sandwich = "{A " + "?" + "B" + ":" + " C}";
+ else if ("null_coalescing".equals(operation))
+ sandwich = "{A " + "??" + "B" + "}";
+ else if ("elvis".equals(operation))
+ sandwich = "{A " + "?:" + "B" + "}";
else // binary operation
sandwich = "{A " + operation + " B}";
diff --git a/tck/src/main/java/com/sun/ts/tests/el/spec/elvisoperator/ELClientIT.java b/tck/src/main/java/com/sun/ts/tests/el/spec/elvisoperator/ELClientIT.java
new file mode 100644
index 0000000..aa1473e
--- /dev/null
+++ b/tck/src/main/java/com/sun/ts/tests/el/spec/elvisoperator/ELClientIT.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+package com.sun.ts.tests.el.spec.elvisoperator;
+
+import com.sun.ts.tests.el.common.util.ExprEval;
+import com.sun.ts.tests.el.common.util.NameValuePair;
+
+import jakarta.el.ELException;
+
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
+
+import java.lang.System.Logger;
+
+public class ELClientIT {
+
+ private static final Logger logger = System.getLogger(ELClientIT.class.getName());
+
+ private final boolean[] deferred = { true, false };
+
+ @AfterEach
+ public void cleanup() throws Exception {
+ logger.log(Logger.Level.INFO, "Cleanup method called");
+ }
+
+ @BeforeEach
+ void logStartTest(TestInfo testInfo) {
+ logger.log(Logger.Level.INFO, "STARTING TEST : " + testInfo.getDisplayName());
+ }
+
+ @AfterEach
+ void logFinishTest(TestInfo testInfo) {
+ logger.log(Logger.Level.INFO, "FINISHED TEST : " + testInfo.getDisplayName());
+ }
+
+ @Test
+ public void elEvlisNullTest() throws Exception {
+
+ boolean pass = false;
+
+ String[] symbols = { "$", "#" };
+ String expectedResult = "default";
+
+ try {
+ for (String prefix : symbols) {
+ /*
+ * Elvis operator expects a boolean first operand so null is coerced to false. First operand is false so
+ * the second operand is returned.
+ */
+ String expr = prefix + "{null ?: 'default'}";
+ Object result = ExprEval.evaluateValueExpression(expr, null, String.class);
+
+ if (result == null) {
+ logger.log(Logger.Level.TRACE, "result is null");
+ } else {
+ logger.log(Logger.Level.TRACE, "result is " + result.toString());
+ }
+
+ pass = (ExprEval.compareClass(result, String.class) && ExprEval.compareValue(result, expectedResult));
+
+ if (!pass) {
+ throw new Exception("TEST FAILED: pass = false");
+ }
+ }
+ } catch (Exception e) {
+ throw new Exception(e);
+ }
+ }
+
+ @Test
+ public void elElvisEmptyStringTest() throws Exception {
+ this.testElvisOperator("", "default");
+ }
+
+ @Test
+ public void elElvisNonTrueStringTest() throws Exception {
+ this.testElvisOperator("other", "default");
+ }
+
+ @Test
+ public void elElvisFalseStringTest() throws Exception {
+ this.testElvisOperator("false", "default");
+ }
+
+ @Test
+ public void elElvisTrueStringTest() throws Exception {
+ String testVal = "true";
+ this.testElvisOperator(testVal, testVal);
+ }
+
+ @Test
+ public void elElvisLongTest() throws Exception {
+ assertThrows(ELException.class, () -> {
+ this.testElvisOperator(Long.valueOf(0), "ignored");
+ });
+ }
+
+ // ---------------------------------------------------------- private methods
+
+ // Test Evlis.
+ private void testElvisOperator(Object testVal, Object expectedResult) throws Exception {
+
+ boolean pass = false;
+
+ NameValuePair value[] = NameValuePair.buildNameValuePair(testVal, "default");
+
+ try {
+ for (boolean tf : deferred) {
+ String expr = ExprEval.buildElExpr(tf, "elvis");
+ Object result = ExprEval.evaluateValueExpression(expr, value, Object.class);
+
+ logger.log(Logger.Level.TRACE, "result is " + result.toString());
+
+ pass = (ExprEval.compareClass(result, expectedResult.getClass()) && ExprEval.compareValue(result, expectedResult));
+
+ if (!pass) {
+ throw new Exception("TEST FAILED: pass = false");
+ }
+ }
+ } catch (ELException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new Exception(e);
+ }
+ }
+}
diff --git a/tck/src/main/java/com/sun/ts/tests/el/spec/nullcoalescingoperator/ELClientIT.java b/tck/src/main/java/com/sun/ts/tests/el/spec/nullcoalescingoperator/ELClientIT.java
new file mode 100644
index 0000000..b8b7a3b
--- /dev/null
+++ b/tck/src/main/java/com/sun/ts/tests/el/spec/nullcoalescingoperator/ELClientIT.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2025 Contributors to the Eclipse Foundation
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v. 2.0, which is available at
+ * http://www.eclipse.org/legal/epl-2.0.
+ *
+ * This Source Code may also be made available under the following Secondary
+ * Licenses when the conditions for such availability set forth in the
+ * Eclipse Public License v. 2.0 are satisfied: GNU General Public License,
+ * version 2 with the GNU Classpath Exception, which is available at
+ * https://www.gnu.org/software/classpath/license.html.
+ *
+ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
+ */
+package com.sun.ts.tests.el.spec.nullcoalescingoperator;
+
+import com.sun.ts.tests.el.common.util.ExprEval;
+import com.sun.ts.tests.el.common.util.NameValuePair;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
+
+import java.lang.System.Logger;
+
+public class ELClientIT {
+
+ private static final Logger logger = System.getLogger(ELClientIT.class.getName());
+
+ private final boolean[] deferred = { true, false };
+
+ @AfterEach
+ public void cleanup() throws Exception {
+ logger.log(Logger.Level.INFO, "Cleanup method called");
+ }
+
+ @BeforeEach
+ void logStartTest(TestInfo testInfo) {
+ logger.log(Logger.Level.INFO, "STARTING TEST : " + testInfo.getDisplayName());
+ }
+
+ @AfterEach
+ void logFinishTest(TestInfo testInfo) {
+ logger.log(Logger.Level.INFO, "FINISHED TEST : " + testInfo.getDisplayName());
+ }
+
+ @Test
+ public void elNullCoalescingNullTest() throws Exception {
+
+ boolean pass = false;
+
+ String[] symbols = { "$", "#" };
+ String expectedResult = "default";
+
+ try {
+ for (String prefix : symbols) {
+ String expr = prefix + "{null ?? 'default'}";
+ Object result = ExprEval.evaluateValueExpression(expr, null, Object.class);
+
+ if (result == null) {
+ logger.log(Logger.Level.TRACE, "result is null");
+ } else {
+ logger.log(Logger.Level.TRACE, "result is " + result.toString());
+ }
+
+ pass = (ExprEval.compareClass(result, String.class) && ExprEval.compareValue(result, expectedResult));
+
+ if (!pass) {
+ throw new Exception("TEST FAILED: pass = false");
+ }
+ }
+ } catch (Exception e) {
+ throw new Exception(e);
+ }
+ }
+
+ @Test
+ public void elNullCoalescingStringTest() throws Exception {
+ this.testNullCoalescingOperator(null, "default");
+ String testVal = "something";
+ this.testNullCoalescingOperator(testVal, testVal);
+ }
+
+ @Test
+ public void elNullCoalescingLongTest() throws Exception {
+ Long testVal = Long.valueOf(0);
+ this.testNullCoalescingOperator(testVal, testVal);
+ testVal = Long.valueOf(1234);
+ this.testNullCoalescingOperator(testVal, testVal);
+ }
+
+ @Test
+ public void elNullCoalescingArrayTest() throws Exception {
+ String[] testVal = new String[0];
+ this.testNullCoalescingOperator(testVal, testVal);
+ testVal = new String[] { "data" };
+ this.testNullCoalescingOperator(testVal, testVal);
+ }
+
+ // ---------------------------------------------------------- private methods
+
+ // Test Null Coalescing.
+ private void testNullCoalescingOperator(Object testVal, Object expectedResult) throws Exception {
+
+ boolean pass = false;
+
+ NameValuePair value[] = NameValuePair.buildNameValuePair(testVal, "default");
+
+ try {
+ for (boolean tf : deferred) {
+ String expr = ExprEval.buildElExpr(tf, "null_coalescing");
+ Object result = ExprEval.evaluateValueExpression(expr, value, Object.class);
+
+ logger.log(Logger.Level.TRACE, "result is " + result.toString());
+
+ pass = (ExprEval.compareClass(result, expectedResult.getClass()) && ExprEval.compareValue(result, expectedResult));
+
+ if (!pass) {
+ throw new Exception("TEST FAILED: pass = false");
+ }
+ }
+ } catch (Exception e) {
+ throw new Exception(e);
+ }
+ }
+}