diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index af588bba6..13d3f697d 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -25,6 +25,7 @@ and this project adheres to https://semver.org/spec/v2.0.0.html[Semantic Version === Added +- Add support for scalar function expressions (UPPER, LOWER, LEFT, RIGHT, LENGTH, ABS) in JDQL string queries - Include support to Restriction interface - Include support to record projector - Include Is annotation support to the Repository diff --git a/jnosql-communication/jnosql-communication-query/antlr4/org/eclipse/jnosql/query/grammar/data/JDQL.g4 b/jnosql-communication/jnosql-communication-query/antlr4/org/eclipse/jnosql/query/grammar/data/JDQL.g4 index 2ca307525..678750150 100644 --- a/jnosql-communication/jnosql-communication-query/antlr4/org/eclipse/jnosql/query/grammar/data/JDQL.g4 +++ b/jnosql-communication/jnosql-communication-query/antlr4/org/eclipse/jnosql/query/grammar/data/JDQL.g4 @@ -68,12 +68,7 @@ primary_expression ; function_expression - : ('abs(' | 'ABS(') scalar_expression ')' - | ('length(' | 'LENGTH(') scalar_expression ')' - | ('lower(' | 'LOWER(') scalar_expression ')' - | ('upper(' | 'UPPER(') scalar_expression ')' - | ('left(' | 'LEFT(') scalar_expression ',' scalar_expression ')' - | ('right(' | 'RIGHT(') scalar_expression ',' scalar_expression ')' + : (ABS | LENGTH | LOWER | UPPER | LEFT | RIGHT) '(' scalar_expression (',' scalar_expression)* ')' ; special_expression @@ -84,9 +79,11 @@ special_expression | FALSE ; -state_field_path_expression : IDENTIFIER (DOT IDENTIFIER)* | FULLY_QUALIFIED_IDENTIFIER | FUNCTION_ID; +state_field_path_expression : identifier (DOT identifier)* | FULLY_QUALIFIED_IDENTIFIER | FUNCTION_ID; -entity_name : IDENTIFIER; // no ambiguity +identifier : IDENTIFIER | ABS | LENGTH | LOWER | UPPER | LEFT | RIGHT; + +entity_name : identifier; // no ambiguity enum_literal : IDENTIFIER (DOT IDENTIFIER)* | FULLY_QUALIFIED_IDENTIFIER; // ambiguity with state_field_path_expression resolvable semantically @@ -120,6 +117,12 @@ LOCAL_TIME : [lL][oO][cC][aA][lL] [tT][iI][mM][eE]; BETWEEN : [bB][eE][tT][wW][eE][eE][nN]; LIKE : [lL][iI][kK][eE]; THIS : [tT][hH][iI][sS]; +ABS : [aA][bB][sS]; +LENGTH : [lL][eE][nN][gG][tT][hH]; +LOWER : [lL][oO][wW][eE][rR]; +UPPER : [uU][pP][pP][eE][rR]; +LEFT : [lL][eE][fF][tT]; +RIGHT : [rR][iI][gG][hH][tT]; LOCAL : [lL][oO][cC][aA][lL]; DATE : [dD][aA][tT][eE]; DATETIME : [dD][aA][tT][eE][tT][iI][mM][eE]; diff --git a/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/AbstractWhere.java b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/AbstractWhere.java index dd564b23e..55b932f70 100644 --- a/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/AbstractWhere.java +++ b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/AbstractWhere.java @@ -8,6 +8,7 @@ * You may elect to redistribute this code under either of these licenses. * Contributors: * Otavio Santana + * Matheus Oliveira */ package org.eclipse.jnosql.communication.query.data; @@ -185,11 +186,6 @@ public void exitIn_expression(JDQLParser.In_expressionContext ctx) { and = andCondition; } - @Override - public void exitFunction_expression(JDQLParser.Function_expressionContext ctx) { - throw new UnsupportedOperationException("The function is not supported in the query: " + ctx.getText()); - } - private Condition getCondition(JDQLParser.Comparison_expressionContext ctx) { var context = ctx.comparison_operator(); if (context.EQ() != null) { diff --git a/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/DefaultFunction.java b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/DefaultFunction.java new file mode 100644 index 000000000..ca1fc51f9 --- /dev/null +++ b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/DefaultFunction.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * You may elect to redistribute this code under either of these licenses. + * Contributors: + * Matheus Oliveira + */ +package org.eclipse.jnosql.communication.query.data; + +import org.eclipse.jnosql.communication.query.Function; + +import java.util.Arrays; +import java.util.Objects; + +/** + * The default implementation of {@link Function}. + * + * @param name the function name (e.g., {@code "UPPER"}) + * @param params the function parameters + */ +record DefaultFunction(String name, Object... params) implements Function { + + public DefaultFunction { + Objects.requireNonNull(name, "name is required"); + Objects.requireNonNull(params, "params is required"); + } + + @Override + public String toString() { + return name + "(" + Arrays.toString(params) + ")"; + } +} diff --git a/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/FunctionQueryValue.java b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/FunctionQueryValue.java new file mode 100644 index 000000000..c18cc2845 --- /dev/null +++ b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/FunctionQueryValue.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * You may elect to redistribute this code under either of these licenses. + * Contributors: + * Matheus Oliveira + */ +package org.eclipse.jnosql.communication.query.data; + +import org.eclipse.jnosql.communication.query.Function; +import org.eclipse.jnosql.communication.query.QueryValue; +import org.eclipse.jnosql.communication.query.ValueType; + +import java.util.Objects; + +record FunctionQueryValue(Function function) implements QueryValue { + + FunctionQueryValue { + Objects.requireNonNull(function, "function is required"); + } + + @Override + public Function get() { + return function; + } + + @Override + public ValueType type() { + return ValueType.FUNCTION; + } + + @Override + public String toString() { + return function.toString(); + } +} diff --git a/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/FunctionType.java b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/FunctionType.java new file mode 100644 index 000000000..6f7385d79 --- /dev/null +++ b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/FunctionType.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * You may elect to redistribute this code under either of these licenses. + * Contributors: + * Matheus Oliveira + */ +package org.eclipse.jnosql.communication.query.data; + +import org.eclipse.jnosql.query.grammar.data.JDQLParser; + +import java.util.function.Predicate; + +/** + * Maps each JDQL function keyword token to its canonical function name string. + * + *

Use {@link #from(JDQLParser.Function_expressionContext)} to resolve the + * function name from a grammar context without if-else chains.

+ */ +enum FunctionType { + + ABS(ctx -> ctx.ABS() != null, 1), + LENGTH(ctx -> ctx.LENGTH() != null, 1), + LOWER(ctx -> ctx.LOWER() != null, 1), + UPPER(ctx -> ctx.UPPER() != null, 1), + LEFT(ctx -> ctx.LEFT() != null, 2), + RIGHT(ctx -> ctx.RIGHT() != null, 2); + + private final Predicate matcher; + private final int arity; + + FunctionType(Predicate matcher, int arity) { + this.matcher = matcher; + this.arity = arity; + } + + /** + * Resolves the canonical function name from a grammar context, + * validating that the argument count matches the expected arity. + * + * @param ctx the function expression context; must not be {@code null} + * @return the uppercase function name (e.g., {@code "UPPER"}) + * @throws UnsupportedOperationException if no known function matches + * @throws IllegalArgumentException if the argument count does not match + */ + static String from(JDQLParser.Function_expressionContext ctx) { + for (FunctionType type : values()) { + if (type.matcher.test(ctx)) { + int actual = ctx.scalar_expression().size(); + if (actual != type.arity) { + throw new IllegalArgumentException( + "Function " + type.name() + " expects " + type.arity + + " argument(s) but got " + actual); + } + return type.name(); + } + } + throw new UnsupportedOperationException("The function is not supported yet: " + ctx.getText()); + } +} diff --git a/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/PrimaryFunction.java b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/PrimaryFunction.java index c1e20b98e..e90f7470a 100644 --- a/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/PrimaryFunction.java +++ b/jnosql-communication/jnosql-communication-query/src/main/java/org/eclipse/jnosql/communication/query/data/PrimaryFunction.java @@ -8,6 +8,7 @@ * You may elect to redistribute this code under either of these licenses. * Contributors: * Otavio Santana + * Matheus Oliveira */ package org.eclipse.jnosql.communication.query.data; @@ -53,10 +54,17 @@ public QueryValue apply(JDQLParser.Primary_expressionContext context) { default -> throw new UnsupportedOperationException("The special expression is not supported yet: " + specialExpression); }; - } else if(context.enum_literal() != null) { + } else if (context.function_expression() != null) { + var functionExpression = context.function_expression(); + var functionName = FunctionType.from(functionExpression); + var params = functionExpression.scalar_expression().stream() + .map(this::processScalar) + .toArray(); + return new FunctionQueryValue(new DefaultFunction(functionName, params)); + } else if (context.enum_literal() != null) { Enum value = EnumConverter.INSTANCE.apply(context.enum_literal().getText()); return EnumQueryValue.of(value); - } else if(context.state_field_path_expression() != null) { + } else if (context.state_field_path_expression() != null) { var stateContext = context.state_field_path_expression(); var stateContextText = stateContext.getText(); try { @@ -67,6 +75,16 @@ public QueryValue apply(JDQLParser.Primary_expressionContext context) { return QueryPath.of(stateContextText); } } - throw new UnsupportedOperationException("The primary expression is not supported yet: " + context.getText()); + throw new UnsupportedOperationException("The primary expression is not supported yet: " + context.getText()); } -} \ No newline at end of file + + private Object processScalar(JDQLParser.Scalar_expressionContext context) { + if (context.primary_expression() != null) { + return apply(context.primary_expression()); + } + if (context.LPAREN() != null && context.scalar_expression().size() == 1) { + return processScalar(context.scalar_expression(0)); + } + throw new UnsupportedOperationException("Arithmetic expressions in function arguments are not supported yet: " + context.getText()); + } +} diff --git a/jnosql-communication/jnosql-communication-query/src/test/java/org/eclipse/jnosql/communication/query/data/SelectJakartaDataQueryFunctionTest.java b/jnosql-communication/jnosql-communication-query/src/test/java/org/eclipse/jnosql/communication/query/data/SelectJakartaDataQueryFunctionTest.java new file mode 100644 index 000000000..e9597ddfc --- /dev/null +++ b/jnosql-communication/jnosql-communication-query/src/test/java/org/eclipse/jnosql/communication/query/data/SelectJakartaDataQueryFunctionTest.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * and Apache License v2.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v10.html + * and the Apache License v2.0 is available at http://www.opensource.org/licenses/apache2.0.php. + * You may elect to redistribute this code under either of these licenses. + * Contributors: + * Matheus Oliveira + */ +package org.eclipse.jnosql.communication.query.data; + +import org.assertj.core.api.SoftAssertions; +import org.eclipse.jnosql.communication.Condition; +import org.eclipse.jnosql.communication.query.Function; +import org.eclipse.jnosql.communication.query.ParamQueryValue; +import org.eclipse.jnosql.communication.query.QueryCondition; +import org.eclipse.jnosql.communication.query.SelectQuery; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.stream.Stream; + +class SelectJakartaDataQueryFunctionTest { + + private SelectParser selectParser; + + @BeforeEach + void setUp() { + selectParser = new SelectParser(); + } + + @ParameterizedTest(name = "Should parser the query {0}") + @MethodSource("functionsProvider") + void shouldHandleFunctions(String query, String fieldName, String functionName) { + SelectQuery selectQuery = selectParser.apply(query, "Customer"); + + SoftAssertions.assertSoftly(soft -> { + soft.assertThat(selectQuery.where()).as("where clause is present").isPresent(); + QueryCondition condition = selectQuery.where().get().condition(); + soft.assertThat(condition.name()).as("condition field name").isEqualTo(fieldName); + soft.assertThat(condition.condition()).as("condition type is EQUALS").isEqualTo(Condition.EQUALS); + soft.assertThat(condition.value()).as("condition value is a FunctionQueryValue").isInstanceOf(FunctionQueryValue.class); + Function function = ((FunctionQueryValue) condition.value()).get(); + soft.assertThat(function.name()).as("function name").isEqualTo(functionName); + soft.assertThat(function.params()).as("function has parameters").isNotEmpty(); + }); + } + + static Stream functionsProvider() { + return Stream.of( + Arguments.of("FROM Customer WHERE name = LOWER('JOHN')", "name", "LOWER"), + Arguments.of("FROM Customer WHERE name = lower('JOHN')", "name", "LOWER"), + Arguments.of("FROM Customer WHERE name = UPPER('john')", "name", "UPPER"), + Arguments.of("FROM Customer WHERE name = LENGTH('john')", "name", "LENGTH"), + Arguments.of("FROM Customer WHERE age = ABS(-10)", "age", "ABS"), + Arguments.of("FROM Customer WHERE name = LEFT('Jonathan', 2)", "name", "LEFT"), + Arguments.of("FROM Customer WHERE name = RIGHT('Jonathan', 1)", "name", "RIGHT") + ); + } + + @ParameterizedTest(name = "Should parser the query {0}") + @MethodSource("nestedFunctionsProvider") + void shouldHandleNestedFunctions(String query, String outerFunc, String innerFunc) { + SelectQuery selectQuery = selectParser.apply(query, "Customer"); + + SoftAssertions.assertSoftly(soft -> { + soft.assertThat(selectQuery.where()).as("where clause is present").isPresent(); + QueryCondition condition = selectQuery.where().get().condition(); + soft.assertThat(condition.value()).as("outer value is a FunctionQueryValue").isInstanceOf(FunctionQueryValue.class); + Function outer = ((FunctionQueryValue) condition.value()).get(); + soft.assertThat(outer.name()).as("outer function name").isEqualTo(outerFunc); + soft.assertThat(outer.params()[0]).as("inner param is a FunctionQueryValue").isInstanceOf(FunctionQueryValue.class); + Function inner = ((FunctionQueryValue) outer.params()[0]).get(); + soft.assertThat(inner.name()).as("inner function name").isEqualTo(innerFunc); + }); + } + + static Stream nestedFunctionsProvider() { + return Stream.of( + Arguments.of("FROM Customer WHERE name = UPPER(LOWER(:name))", "UPPER", "LOWER"), + Arguments.of("FROM Customer WHERE name = LOWER(UPPER('john'))", "LOWER", "UPPER") + ); + } + + @ParameterizedTest(name = "Should parser the query {0}") + @MethodSource("parametersProvider") + void shouldHandleFunctionsWithParameters(String query, String functionName, String paramName) { + SelectQuery selectQuery = selectParser.apply(query, "Customer"); + + SoftAssertions.assertSoftly(soft -> { + soft.assertThat(selectQuery.where()).as("where clause is present").isPresent(); + QueryCondition condition = selectQuery.where().get().condition(); + soft.assertThat(condition.condition()).as("condition type is EQUALS").isEqualTo(Condition.EQUALS); + soft.assertThat(condition.value()).as("condition value is a FunctionQueryValue").isInstanceOf(FunctionQueryValue.class); + Function function = ((FunctionQueryValue) condition.value()).get(); + soft.assertThat(function.name()).as("function name").isEqualTo(functionName); + soft.assertThat(function.params()[0]).as("first param is a ParamQueryValue").isInstanceOf(ParamQueryValue.class); + soft.assertThat(((ParamQueryValue) function.params()[0]).get()).as("param name").isEqualTo(paramName); + }); + } + + static Stream parametersProvider() { + return Stream.of( + Arguments.of("FROM Customer WHERE name = LOWER(:name)", "LOWER", "name"), + Arguments.of("FROM Customer WHERE name = UPPER(?1)", "UPPER", "?1"), + Arguments.of("FROM Customer WHERE age = ABS((:age))", "ABS", "age") + ); + } + + @ParameterizedTest(name = "Should parser the query {0}") + @MethodSource("fieldCollisionProvider") + void shouldHandleFieldNamesSameAsFunctionNames(String query, String fieldName) { + SelectQuery selectQuery = selectParser.apply(query, "Box"); + + SoftAssertions.assertSoftly(soft -> { + soft.assertThat(selectQuery.where()).as("where clause is present").isPresent(); + QueryCondition condition = selectQuery.where().get().condition(); + soft.assertThat(condition.name()).as("condition field name").isEqualTo(fieldName); + }); + } + + static Stream fieldCollisionProvider() { + return Stream.of( + Arguments.of("FROM Box WHERE length = 10", "length"), + Arguments.of("FROM Box WHERE ABS(length) = 10", "ABS(length)"), + Arguments.of("FROM Box WHERE left = 'a'", "left") + ); + } + + @ParameterizedTest(name = "Should parser the query {0}") + @MethodSource("wrongArityProvider") + void shouldRejectWrongArity(String query) { + Assertions.assertThrows(IllegalArgumentException.class, + () -> selectParser.apply(query, "Customer")); + } + + static Stream wrongArityProvider() { + return Stream.of( + Arguments.of("FROM Customer WHERE name = UPPER('a', 'b')"), + Arguments.of("FROM Customer WHERE name = LOWER('a', 'b')"), + Arguments.of("FROM Customer WHERE name = ABS(-1, 2)"), + Arguments.of("FROM Customer WHERE name = LENGTH('a', 'b')"), + Arguments.of("FROM Customer WHERE name = LEFT('a')") + ); + } + + @ParameterizedTest(name = "Should parser the query {0}") + @ValueSource(strings = { + "FROM Customer WHERE age = ABS(1 + 2)", + "FROM Customer WHERE age = ABS(price * 2)" + }) + void shouldRejectArithmeticInFunctionArguments(String query) { + Assertions.assertThrows(UnsupportedOperationException.class, + () -> selectParser.apply(query, "Customer")); + } +}