This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-24921 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 493f25117b3c3acf891af9495b899c7cba4adbd5 Author: Claus Ibsen <[email protected]> AuthorDate: Wed Sep 23 11:44:09 2026 +0200 CAMEL-24921: simple - read a predicate inside the braces --- .../camel/catalog/docs/simple-operators.adoc | 18 ++++ .../modules/languages/pages/simple-operators.adoc | 18 ++++ .../camel/language/simple/SimpleSyntaxHints.java | 89 ++++++++++++++++++++ .../simple/ast/SimpleFunctionExpression.java | 45 +++++++++- .../language/simple/ast/SimpleFunctionStart.java | 85 +------------------ .../simple/SimplePredicateInBracesTest.java | 98 ++++++++++++++++++++++ .../language/simple/SimpleSyntaxHintsTest.java | 16 +++- .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc | 13 +++ 8 files changed, 294 insertions(+), 88 deletions(-) diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-operators.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-operators.adoc index 7ce0e007b06b..ff9580d8f3f8 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-operators.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/simple-operators.adoc @@ -24,6 +24,24 @@ Camel will automatically type convert the rightValue type to the leftValue type, so it is able to e.g., convert a string into a numeric, so you can use `>` comparison for numeric values. +=== Where the operators go + +An operator can be written inside `${ }` or outside it: + +[source,text] +---- +${body != null && body.size() > 0} <1> +${body} != null && ${body.size()} > 0 <2> +---- +<1> the braces hold a predicate, and answer whether it matches +<2> the operators are between the functions, and the whole text is the predicate + +Both mean the same thing. Inside the braces the answer is a value, so it can be used in a template as +well: `Over three: ${header.n > 3}` gives `Over three: true`. + +An operator is only an operator when whitespace surrounds it, which is what keeps a name such as +`${header.Content-Length}` and a pattern such as `${date:now:yyyy-MM-dd}` a plain function. + === Comparison Operators The following comparison operators are supported: diff --git a/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-operators.adoc b/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-operators.adoc index 7ce0e007b06b..ff9580d8f3f8 100644 --- a/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-operators.adoc +++ b/core/camel-core-languages/src/main/docs/modules/languages/pages/simple-operators.adoc @@ -24,6 +24,24 @@ Camel will automatically type convert the rightValue type to the leftValue type, so it is able to e.g., convert a string into a numeric, so you can use `>` comparison for numeric values. +=== Where the operators go + +An operator can be written inside `${ }` or outside it: + +[source,text] +---- +${body != null && body.size() > 0} <1> +${body} != null && ${body.size()} > 0 <2> +---- +<1> the braces hold a predicate, and answer whether it matches +<2> the operators are between the functions, and the whole text is the predicate + +Both mean the same thing. Inside the braces the answer is a value, so it can be used in a template as +well: `Over three: ${header.n > 3}` gives `Over three: true`. + +An operator is only an operator when whitespace surrounds it, which is what keeps a name such as +`${header.Content-Length}` and a pattern such as `${date:now:yyyy-MM-dd}` a plain function. + === Comparison Operators The following comparison operators are supported: diff --git a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java index f56601ce9c2d..389ae91d176c 100644 --- a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java +++ b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/SimpleSyntaxHints.java @@ -149,6 +149,95 @@ public final class SimpleSyntaxHints { + VALUE_FORMS + (isKnownFunction(functionName(word)) ? ", e.g. ${" + word + "}" : ", e.g. '" + word + "'"); } + /** The comparison operators, with the spaces they must be surrounded by. */ + private static final String[] SPACED_OPERATORS = { + " >= ", " <= ", " > ", " < ", " == ", " != ", " =~ ", " !=~ ", + " contains ", " !contains ", " ~~ ", " !~~ ", " regex ", " !regex ", + " in ", " !in ", " is ", " !is ", " range ", " !range ", + " startsWith ", " !startsWith ", " endsWith ", " !endsWith " }; + + /** + * Wraps the function references of a predicate written inside {@code ${ }} so that it can be parsed as one: + * {@code body != null && body.size() > 0} becomes {@code ${body} != null && ${body.size()} > 0}. + * <p/> + * Each comparison is wrapped, not only the first, so that a compound condition reads the way it looks (CAMEL-24920, + * CAMEL-24921). An operator counts only when whitespace surrounds it outside quotes, which is what keeps + * {@code ${header.Content-Length}} and {@code ${date:now:yyyy-MM-dd}} a plain function. + */ + public static String wrapFunctions(String text) { + StringBuilder answer = new StringBuilder(); + int from = 0; + for (int at = logicalOperator(text, 0); at >= 0; at = logicalOperator(text, from)) { + int end = text.indexOf(' ', at + 1); + if (end < 0) { + break; // the operator has nothing after it: leave it to the parser to say so + } + answer.append(wrapComparison(text.substring(from, at).trim())); + answer.append(' ').append(text, at, end).append(' '); + from = end + 1; + } + answer.append(wrapComparison(text.substring(from).trim())); + return answer.toString(); + } + + /** + * The index of the next logical operator ({@code &&} or {@code ||}) outside quotes, or -1. Simple has no word + * forms: {@code and} and {@code or} are refused by the parser with a message that says so. + */ + private static int logicalOperator(String text, int from) { + boolean single = false; + boolean dubble = false; + for (int i = from; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '\'' && !dubble) { + single = !single; + } else if (c == '"' && !single) { + dubble = !dubble; + } else if (!single && !dubble && c == ' ') { + for (String op : new String[] { "&& ", "|| " }) { + if (text.startsWith(op, i + 1)) { + return i + 1; + } + } + } + } + return -1; + } + + /** Wraps the left hand side of one comparison with {@code ${ }} when it is a function reference. */ + private static String wrapComparison(String text) { + for (String op : SPACED_OPERATORS) { + int at = text.indexOf(op); + if (at < 0 && text.endsWith(op.stripTrailing())) { + // the operator ends the text: wrap what is there, so the parser says what is missing after it + at = text.length() - op.stripTrailing().length(); + } + if (at > 0) { + String left = text.substring(0, at).trim(); + String right = at + op.length() <= text.length() ? text.substring(at + op.length()).trim() : ""; + if (!left.startsWith("${") && !left.startsWith("'") && !left.startsWith("\"") + && !isNumeric(left) && !"true".equalsIgnoreCase(left) + && !"false".equalsIgnoreCase(left) && !"null".equalsIgnoreCase(left)) { + left = "${" + left + "}"; + } + return left + op + right; + } + } + return text; + } + + private static boolean isNumeric(String text) { + if (text == null || text.isEmpty()) { + return false; + } + try { + Double.parseDouble(text); + return true; + } catch (NumberFormatException e) { + return false; + } + } + /** * When an operator is written inside the function (${body == 'x'}), the rewrite with the operator outside, else * null. diff --git a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java index c120eabf3082..0b8f61958605 100644 --- a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java +++ b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionExpression.java @@ -19,10 +19,13 @@ package org.apache.camel.language.simple.ast; import java.util.Map; import org.apache.camel.CamelContext; +import org.apache.camel.Exchange; import org.apache.camel.Expression; +import org.apache.camel.Predicate; import org.apache.camel.language.simple.FileExpressionBuilder; import org.apache.camel.language.simple.SimpleFunctionDispatcher; import org.apache.camel.language.simple.SimpleFunctionHelper; +import org.apache.camel.language.simple.SimplePredicateParser; import org.apache.camel.language.simple.SimpleSyntaxHints; import org.apache.camel.language.simple.functions.DirectFunctionFactory; import org.apache.camel.language.simple.types.SimpleParserException; @@ -98,12 +101,46 @@ public class SimpleFunctionExpression extends LiteralExpression { private static final DirectFunctionFactory DIRECT_FACTORY = new DirectFunctionFactory(); + /** + * A predicate written inside the braces, as an expression that answers whether it matches; null when the text is + * not a predicate but a plain function (CAMEL-24921). + * <p/> + * An operator counts only when whitespace surrounds it outside quotes, so {@code ${header.Content-Length}} and + * {@code ${date:now:yyyy-MM-dd}} are names, not arithmetic. + */ + private Expression createPredicateExpression(CamelContext camelContext, String function) { + if (SimpleSyntaxHints.operatorsOutside(function) == null) { + return null; + } + String text = SimpleSyntaxHints.wrapFunctions(function); + final Predicate predicate; + try { + predicate = new SimplePredicateParser(camelContext, text, true, skipFileFunctions, null).parsePredicate(); + } catch (SimpleParserException e) { + // not a predicate after all: say what is wrong with it, at the place it went wrong + throw new SimpleParserException(e.getMessage(), token.getIndex()); + } + return new Expression() { + @Override + public <T> T evaluate(Exchange exchange, Class<T> type) { + boolean matches = predicate.matches(exchange); + return exchange.getContext().getTypeConverter().convertTo(type, exchange, matches); + } + + @Override + public String toString() { + return text; + } + }; + } + private Expression doCreateSimpleExpression(CamelContext camelContext, String function, boolean strict) { if (strict) { - // ${body == 'x'}: the operator belongs outside the function (CAMEL-24703) - String rewrite = SimpleSyntaxHints.operatorsOutside(function); - if (rewrite != null) { - throw new SimpleParserException("Operators go outside the function: " + rewrite, token.getIndex()); + // ${body != null && body.size() > 0}: the braces hold a predicate, which is what they hold in EL, + // Groovy and a JavaScript template, so read it as one (CAMEL-24921) + Expression predicate = createPredicateExpression(camelContext, function); + if (predicate != null) { + return predicate; } } // return the function directly if we can create function without analyzing the prefix diff --git a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionStart.java b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionStart.java index 208741b3bfaf..54a320073a09 100644 --- a/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionStart.java +++ b/core/camel-core-languages/src/main/java/org/apache/camel/language/simple/ast/SimpleFunctionStart.java @@ -25,6 +25,7 @@ import org.apache.camel.Expression; import org.apache.camel.Predicate; import org.apache.camel.language.simple.SimpleExpressionParser; import org.apache.camel.language.simple.SimplePredicateParser; +import org.apache.camel.language.simple.SimpleSyntaxHints; import org.apache.camel.language.simple.types.SimpleIllegalSyntaxException; import org.apache.camel.language.simple.types.SimpleParserException; import org.apache.camel.language.simple.types.SimpleToken; @@ -332,88 +333,8 @@ public class SimpleFunctionStart extends BaseSimpleNode implements BlockStart { if (conditionText.contains("${")) { return conditionText; } - - // the condition may be several comparisons joined by a logical operator, and each of them needs its - // functions wrapped, not only the first (CAMEL-24920) - StringBuilder answer = new StringBuilder(); - int from = 0; - for (int at = logicalOperator(conditionText, 0); at >= 0; at = logicalOperator(conditionText, from)) { - int end = conditionText.indexOf(' ', at + 1); - if (end < 0) { - break; // the operator has nothing after it: leave it to the predicate parser to say so - } - answer.append(wrapComparison(conditionText.substring(from, at).trim())); - answer.append(' ').append(conditionText, at, end).append(' '); - from = end + 1; - } - answer.append(wrapComparison(conditionText.substring(from).trim())); - return answer.toString(); - } - - /** - * The index of the next logical operator ({@code &&} or {@code ||}) outside quotes, or -1. Simple has no word - * forms: {@code and} and {@code or} are refused by the parser with a message that says so. - */ - private static int logicalOperator(String text, int from) { - boolean single = false; - boolean dubble = false; - for (int i = from; i < text.length(); i++) { - char c = text.charAt(i); - if (c == '\'' && !dubble) { - single = !single; - } else if (c == '"' && !single) { - dubble = !dubble; - } else if (!single && !dubble && c == ' ') { - for (String op : new String[] { "&& ", "|| " }) { - if (text.startsWith(op, i + 1)) { - return i + 1; - } - } - } - } - return -1; - } - - /** Wraps the left hand side of one comparison with ${} when it is a function reference. */ - private String wrapComparison(String conditionText) { - // Find the operator in the condition - String[] operators = { - " >= ", " <= ", " > ", " < ", " == ", " != ", " =~ ", " !=~ ", - " contains ", " !contains ", " ~~ ", " !~~ ", " regex ", " !regex ", - " in ", " !in ", " is ", " !is ", " range ", " !range ", - " startsWith ", " !startsWith ", " endsWith ", " !endsWith " }; - - for (String op : operators) { - int opIdx = conditionText.indexOf(op); - if (opIdx > 0) { - String leftSide = conditionText.substring(0, opIdx).trim(); - String rightSide = conditionText.substring(opIdx + op.length()).trim(); - - // Wrap the left side with ${} if it looks like a function reference - if (!leftSide.startsWith("${") && !leftSide.startsWith("'") && !leftSide.startsWith("\"") - && !isNumeric(leftSide) && !"true".equalsIgnoreCase(leftSide) - && !"false".equalsIgnoreCase(leftSide) && !"null".equalsIgnoreCase(leftSide)) { - leftSide = "${" + leftSide + "}"; - } - - return leftSide + op + rightSide; - } - } - - // No operator found, return as-is - return conditionText; - } - - private boolean isNumeric(String str) { - if (str == null || str.isEmpty()) { - return false; - } - try { - Double.parseDouble(str); - return true; - } catch (NumberFormatException e) { - return false; - } + // the same wrapping a predicate written inside ${ } gets, so the two cannot drift (CAMEL-24920) + return SimpleSyntaxHints.wrapFunctions(conditionText); } /** diff --git a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimplePredicateInBracesTest.java b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimplePredicateInBracesTest.java new file mode 100644 index 000000000000..bdd7d9d67491 --- /dev/null +++ b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimplePredicateInBracesTest.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.language.simple; + +import java.util.List; +import java.util.Map; + +import org.apache.camel.LanguageTestSupport; +import org.junit.jupiter.api.Test; + +/** + * CAMEL-24921: {@code ${ }} may hold a predicate, the way the braces do in Jakarta EL, Groovy and a JavaScript + * template, instead of refusing the shape. + */ +public class SimplePredicateInBracesTest extends LanguageTestSupport { + + @Override + protected String getLanguageName() { + return "simple"; + } + + @Test + public void testPredicateInsideTheBraces() { + exchange.getIn().setBody(List.of(Map.of("sku", "CAMEL-MUG"))); + assertPredicate("${body != null && body.size() > 0}", true); + assertExpression("${body != null && body.size() > 0}", "true"); + + exchange.getIn().setBody(List.of()); + assertPredicate("${body != null && body.size() > 0}", false); + } + + @Test + public void testOneComparison() { + exchange.getIn().setHeader("n", 5); + assertPredicate("${header.n > 0}", true); + assertPredicate("${header.n > 10}", false); + assertPredicate("${header.n == 5}", true); + exchange.getIn().setBody("Hello"); + assertPredicate("${body contains 'ell'}", true); + assertPredicate("${body.length() > 3}", true); + } + + @Test + public void testBothFormsAgree() { + exchange.getIn().setHeader("n", 5); + assertPredicate("${header.n > 0 && header.n < 10}", true); + assertPredicate("${header.n} > 0 && ${header.n} < 10", true); + assertPredicate("${header.n > 0 || header.n > 100}", true); + } + + @Test + public void testAValueInTextIsStillATemplate() { + exchange.getIn().setHeader("n", 5); + // outside the braces the expression is a template, and that has not changed + assertExpression("${header.n} > 0", "5 > 0"); + // inside the braces it is the answer of the predicate, which a template can now hold + assertExpression("Count ${header.n} is over three: ${header.n > 3}", "Count 5 is over three: true"); + } + + @Test + public void testANameIsNotArithmetic() { + // an operator counts only when whitespace surrounds it, so these are names and patterns as before + exchange.getIn().setHeader("Content-Length", 42); + assertExpression("${header.Content-Length}", "42"); + exchange.getIn().setHeader("aws-s3-bucket", "orders"); + assertExpression("${header.aws-s3-bucket}", "orders"); + assertExpression("${date:now:yyyy-MM-dd}".substring(0, 10) + "}", null, true); + } + + @Test + public void testTheTernaryStillWins() { + exchange.getIn().setHeader("n", 5); + assertExpression("${header.n > 0 ? 'positive' : 'negative'}", "positive"); + assertExpression("${header.n > 0 && header.n < 10 ? 'in' : 'out'}", "in"); + } + + private void assertExpression(String expression, Object expected, boolean onlyParse) { + if (onlyParse) { + context.resolveLanguage("simple").createExpression(expression); + return; + } + assertExpression(expression, expected); + } +} diff --git a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java index 38dbdbc25faa..b67842929599 100644 --- a/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java +++ b/core/camel-core/src/test/java/org/apache/camel/language/simple/SimpleSyntaxHintsTest.java @@ -50,7 +50,15 @@ public class SimpleSyntaxHintsTest extends ExchangeTestSupport { @Test public void testOperatorInsideFunction() { - assertThat(predicateError("${body == 'x'}")).contains("Operators go outside the function: ${body} == 'x'"); + // CAMEL-24921: the braces may hold a predicate, which is then what they answer + exchange.getIn().setBody("x"); + assertEquals(true, context.resolveLanguage("simple").createPredicate("${body == 'x'}").matches(exchange)); + exchange.getIn().setBody("y"); + assertEquals(false, context.resolveLanguage("simple").createPredicate("${body == 'x'}").matches(exchange)); + // and what is inside must still be a predicate the parser understands, reported against the wrapped text + assertThat(predicateError("${body == }")) + .contains("Unexpected token ==") + .contains("${body} =="); } @Test @@ -130,7 +138,11 @@ public class SimpleSyntaxHintsTest extends ExchangeTestSupport { @Test public void testOperatorAfterOgnlMethod() { - assertThat(predicateError("${body.length() > 3}")).contains("Operators go outside the function: ${body.length()} > 3"); + // CAMEL-24921: an OGNL call on the left of the operator is wrapped as the function it is + exchange.getIn().setBody("hello"); + assertEquals(true, context.resolveLanguage("simple").createPredicate("${body.length() > 3}").matches(exchange)); + exchange.getIn().setBody("hi"); + assertEquals(false, context.resolveLanguage("simple").createPredicate("${body.length() > 3}").matches(exchange)); } @Test diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc index f92b5daa8346..3ae2659fe0de 100644 --- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc +++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc @@ -19,6 +19,19 @@ OAuth client credentials token caching now distinguishes profiles by client secr === Simple language +`${ }` may now hold a predicate, as the braces do in Jakarta EL, Groovy and a JavaScript template: +`${body != null && body.size() > 0}` answers whether it matches, instead of being refused with +_Operators go outside the function_. The form with the operators outside the braces +(`${body} != null && ${body.size()} > 0`) means the same and is unchanged, and the ternary keeps +working as before. + +An operator counts only when whitespace surrounds it outside quotes, so a name such as +`${header.Content-Length}` and a pattern such as `${date:now:yyyy-MM-dd}` are read as they were. +Only expressions that used to throw can now return a value: every one of the 804 distinct simple +expressions in camel, camel-kamelets, camel-examples and camel-jbang-examples parses to what it +parsed to before. + + The simple language reads a `Map` with a dot as well as with a key: `${body.sku}` answers the `sku` entry of a map body when the map has no `sku()` method, the same value `${body[sku]}` gives. A method of the map still wins, so `${body.size}` calls `size()` as before, and a name that is neither a method nor a key still fails. Only expressions
