Move Query Language (RSQL) in separate package and add Entity Matcher (#2531)

* Move Query Language (RSQL) in separate package - hawkbit-repository-ql
* Add Entity Matcher which match an entity object agains filter
* Spec to string utils now in runtime (as a library) - could be used in tests or to dump something in runtimes
* Move eclipselink/hibernate profiles in new QL module, this way provided / set to hawkbit-repository-jpa
* Remove unused javax.el imports
This commit is contained in:
Avgustin Marinov
2025-07-03 14:41:55 +03:00
committed by GitHub
parent 047f94d4cb
commit 426bdbf179
53 changed files with 326 additions and 275 deletions

View File

@@ -0,0 +1,3 @@
# hawkBit QL
hawkBit Query Language Support (based on RSQL)

View File

@@ -0,0 +1,127 @@
<!--
Copyright (c) 2015 Bosch Software Innovations GmbH and others
This program and the accompanying materials are made
available under the terms of the Eclipse Public License 2.0
which is available at https://www.eclipse.org/legal/epl-2.0/
SPDX-License-Identifier: EPL-2.0
-->
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-repository</artifactId>
<version>${revision}</version>
</parent>
<artifactId>hawkbit-repository-ql</artifactId>
<name>hawkBit :: Query Language</name>
<profiles>
<profile>
<id>eclipselink</id>
<activation>
<property>
<!-- default, if not set (or not hibernate) - eclipse link -->
<name>jpa.vendor</name>
<value>!hibernate</value>
</property>
</activation>
<dependencies>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-repository-jpa-eclipselink</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Static weaver for EclipseLink -->
<plugin>
<groupId>com.ethlo.persistence.tools</groupId>
<artifactId>eclipselink-maven-plugin</artifactId>
<version>${eclipselink.maven.plugin.version}</version>
<executions>
<execution>
<phase>process-classes</phase>
<goals>
<goal>weave</goal>
</goals>
</execution>
</executions>
<configuration>
<basePackage>org.eclipse.hawkbit.repository.jpa.model</basePackage>
</configuration>
<dependencies>
<dependency>
<groupId>org.eclipse.persistence</groupId>
<artifactId>org.eclipse.persistence.jpa</artifactId>
<version>${eclipselink.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</profile>
<profile>
<id>hibernate</id>
<activation>
<property>
<name>jpa.vendor</name>
<value>hibernate</value>
</property>
</activation>
<dependencies>
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-repository-jpa-hibernate</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</profile>
</profiles>
<dependencies>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<exclusions>
<exclusion>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
</exclusion>
</exclusions>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cz.jirutka.rsql</groupId>
<artifactId>rsql-parser</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.eclipse.hawkbit</groupId>
<artifactId>hawkbit-repository-test</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,234 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.ql;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.EQ;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NOT_LIKE;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiFunction;
import org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParser;
/**
* Provides entity matcher that matches an entity object against a filter (a {@link Node} or an RSQL string).
*/
public class EntityMatcher {
private final Node root;
private EntityMatcher(final Node root) {
this.root = root;
}
public static EntityMatcher forNode(final Node root) {
return new EntityMatcher(root);
}
public static EntityMatcher forRsql(final String rsql) {
return forNode(RsqlParser.parse(rsql));
}
public <T> boolean match(final T t) {
return match(t, root);
}
private static <T> boolean match(final T t, final Node node) {
if (node instanceof Node.Comparison comparison) {
final String[] split = comparison.getKey().split("\\.", 2);
try {
final Method fieldGetter = getGetter(t.getClass(), split[0]);
fieldGetter.setAccessible(true);
final Object fieldValue = fieldGetter.invoke(t);
final Operator op = comparison.getOp();
if (Map.class.isAssignableFrom(fieldGetter.getReturnType())) {
if ((op == NE || op == NOT_IN || op == NOT_LIKE)
&& (fieldValue == null || !((Map<?, ?>) fieldValue).containsKey(split[1]))) {
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it's not
return false;
}
return compare(
fieldValue == null ? null : ((Map<?, ?>) fieldValue).get(split[1]),
op,
map(
comparison.getValue(),
(Class<?>) ((ParameterizedType) fieldGetter.getGenericReturnType()).getActualTypeArguments()[1]));
} else if (Collection.class.isAssignableFrom(fieldGetter.getReturnType())) { // Set / List
final Object value;
final BiFunction<Object, Operator, Boolean> compare;
if (split.length == 1) {
value = map(comparison.getValue(), fieldGetter.getReturnType());
compare = (e, operator) -> compare(e, operator, value);
} else {
final Method valueGetter = getGetter(
(Class<?>) ((ParameterizedType) fieldGetter.getGenericReturnType()).getActualTypeArguments()[0], split[1]);
value = map(comparison.getValue(), valueGetter.getReturnType());
compare = (e, operator) -> {
try {
return compare(map(e == null ? null : valueGetter.invoke(e), valueGetter.getReturnType()), operator, value);
} catch (final IllegalAccessException | InvocationTargetException ex) {
throw new IllegalArgumentException(ex);
}
};
}
final Collection<?> set = (Collection<?>) fieldValue;
return switch (op) {
case EQ, GT, GTE, LT, LTE, IN, LIKE -> set == null
? false
: set.stream().anyMatch(e -> compare.apply(e, op));
case NE, NOT_IN, NOT_LIKE -> set == null
? true
: set.stream().noneMatch(e -> compare.apply(e, op == NE ? EQ : op == NOT_IN ? IN : LIKE));
};
} else {
if (split.length == 1) {
return compare(fieldValue, op, map(comparison.getValue(), fieldGetter.getReturnType()));
} else {
if (split[1].contains(".")) {
// nested field access
final String[] nestedSplit = split[1].split("\\.", 2);
final Method nestedFieldGetter = getGetter(fieldGetter.getReturnType(), nestedSplit[0]);
nestedFieldGetter.setAccessible(true);
final Method valueGetter = getGetter(nestedFieldGetter.getReturnType(), nestedSplit[1]);
final Object nestedFieldValue = fieldValue == null ? null : nestedFieldGetter.invoke(fieldValue);
return compare(
nestedFieldValue == null ? null : valueGetter.invoke(nestedFieldValue),
op,
map(comparison.getValue(), valueGetter.getReturnType()));
} else {
final Method valueGetter = getGetter(fieldGetter.getReturnType(), split[1]);
return compare(fieldValue == null ? null : valueGetter.invoke(fieldValue), op,
map(comparison.getValue(), valueGetter.getReturnType()));
}
}
}
} catch (final NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
throw new IllegalArgumentException(e);
}
} else if (node instanceof Node.Logical logical) {
return switch (logical.getOp()) {
case AND -> logical.getChildren().stream().allMatch(child -> match(t, child));
case OR -> logical.getChildren().stream().anyMatch(child -> match(t, child));
};
} else {
throw new IllegalArgumentException("Unsupported node type: " + node.getClass());
}
}
private static <T> Method getGetter(final Class<T> t, final String fieldName) throws NoSuchMethodException {
final String getterLowercase = "get" + fieldName.toLowerCase();
return Arrays.stream(t.getMethods())
.filter(method -> getterLowercase.equals(method.getName().toLowerCase()))
.findFirst()
.map(method -> {
method.setAccessible(true);
return method;
}).orElseThrow(() -> new NoSuchMethodException("No getter found for field: " + fieldName + " in class: " + t.getName()));
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private static Object map(final Object value, final Class<?> type) {
if (value instanceof Collection<?> collection) { // in / out
return collection.stream().map(e -> map(e, type)).toList();
}
if (value == null) {
return null;
} else if (type.isInstance(value)) {
return value;
} else if (type.isEnum()) {
return Enum.valueOf((Class<Enum>) type, value.toString());
} else if (type == Boolean.class || type == boolean.class) {
return Boolean.parseBoolean(value.toString());
} else if (type == Integer.class || type == int.class) {
return Integer.parseInt(value.toString());
} else if (type == Long.class || type == long.class) {
return Long.parseLong(value.toString());
} else if (type == Float.class || type == float.class) {
return Float.parseFloat(value.toString());
} else if (type == Double.class || type == double.class) {
return Double.parseDouble(value.toString());
} else if (type == String.class) {
return String.valueOf(value);
} else {
throw new IllegalArgumentException("Unsupported type: " + type);
}
}
private static boolean compare(final Object o1, final Operator op, final Object o2) {
if ((o1 == null || o2 == null) && // null is not comparable!
(op == GT || op == GTE || op == LT || op == LTE)) {
return false;
}
return switch (op) {
case EQ -> Objects.equals(o1, o2);
case NE -> !Objects.equals(o1, o2);
case GT -> compare(o1, o2) > 0;
case GTE -> compare(o1, o2) >= 0;
case LT -> compare(o1, o2) < 0;
case LTE -> compare(o1, o2) <= 0;
case IN -> in(o1, o2);
case NOT_IN -> !in(o1, o2);
case LIKE -> like(o2, o1);
case NOT_LIKE -> !like(o2, o1);
};
}
@SuppressWarnings("unchecked")
private static int compare(final Object o1, final Object o2) {
return toComparable(o1).compareTo(toComparable(o2));
}
@SuppressWarnings("rawtypes")
private static Comparable toComparable(final Object o) {
if (o instanceof Comparable<?> comparable) {
return comparable;
} else {
throw new IllegalArgumentException("Can't cast " + o.getClass() + " to Comparable");
}
}
private static boolean in(final Object o, final Object elementOrCollection) {
if (elementOrCollection instanceof Collection<?> collection) {
return collection.contains(o);
} else {
return Objects.equals(o, elementOrCollection);
}
}
private static boolean like(final Object pattern, final Object value) {
if (pattern instanceof String patternStr) {
if (value instanceof String valueStr) {
return valueStr.matches(patternStr.replace("\\*", "$").replace("*", ".*").replace("$", "\\*"));
} else if (value == null) {
return false; // null value cannot match any pattern
} else {
throw new IllegalArgumentException("LIKE value must be String. Found: " + value.getClass());
}
} else {
throw new IllegalArgumentException("LIKE pattern must be String. Found: " + (pattern == null ? null : pattern.getClass()));
}
}
}

View File

@@ -0,0 +1,211 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.ql;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import lombok.Builder;
import lombok.Value;
/**
* Node is always in Disjunctive Normal Form (DNF). This means:
* <ul>
* <li>single comparison</li>
* <li>and composition of comparisons</li>
* <li>or of comparisons or and composition of comparisons</li>
* </ul>
* Node: if there are custom implementation they shall also follow this contract!
*/
public interface Node {
default Logical and(final Node other) {
return op(this, Logical.Operator.AND, other);
}
default Logical or(final Node other) {
return op(this, Logical.Operator.OR, other);
}
static Logical op(final Node node1, final Logical.Operator op, final Node node2) {
if (node1 instanceof Logical logical1 && logical1.getOp() == op) {
if (node2 instanceof Logical logical2 && logical2.getOp() == op) { // same op
final List<Node> children = new ArrayList<>(logical1.getChildren().size() + logical2.getChildren().size());
children.addAll(logical1.getChildren());
children.addAll(logical2.getChildren());
return new Logical(op, children);
} else { // node2 is not a logical
final List<Node> children = new ArrayList<>(logical1.getChildren().size() + 1);
children.addAll(logical1.getChildren());
children.add(node2);
return new Logical(op, children);
}
} else if (node2 instanceof Logical logical2 && logical2.getOp() == op) {
final List<Node> children = new ArrayList<>(logical2.getChildren().size() + 1);
children.add(node1);
children.addAll(logical2.getChildren());
return new Logical(op, children);
} else {
return new Logical(op, List.of(node1, node2));
}
}
@Value
class Logical implements Node {
public enum Operator {
AND("&&"),
OR("||");
private final String symbol;
Operator(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
Logical.Operator op;
List<Node> children;
public Logical(final Operator op, final List<Node> children) {
Objects.requireNonNull(op, "Operator must not be null");
if (children == null || children.size() < 2) {
throw new IllegalArgumentException("Children of a logical must not be null or empty or single element list");
}
this.op = op;
this.children = children;
}
@Override
public String toString() {
return children.stream()
.map(child -> op == Operator.OR || child instanceof Comparison
? child.toString()
: "(" + child.toString() + ")")
.reduce((a, b) -> a + " " + op + " " + b)
.orElse("");
}
}
@Value
@Builder(builderClassName = "Builder")
class Comparison implements Node {
public enum Operator {
EQ("=="),
NE("!="),
GT(">"),
LT("<"),
GTE(">="),
LTE("<="),
IN("in"),
NOT_IN("not in"),
LIKE("like"),
NOT_LIKE("not like");
private final String symbol;
Operator(String symbol) {
this.symbol = symbol;
}
@Override
public String toString() {
return symbol;
}
}
@Nonnull
String key;
@Nonnull
Operator op;
@Nullable
Object value; // String, Number, Boolean, String[] or Number[] or null
@Nullable
Object context;
public Comparison(@Nonnull final String key, @Nonnull final Operator op, @Nullable final Object value) {
this(key, op, value, null);
}
public Comparison(@Nonnull final String key, @Nonnull final Operator op, @Nullable final Object value, @Nullable final Object context) {
Objects.requireNonNull(key, "Key must not be null");
if (key.trim().isEmpty()) {
throw new IllegalArgumentException("Key must not be empty");
}
Objects.requireNonNull(op, "Operator must not be null");
if (value == null) {
switch (op) {
case GT, LT, LIKE, NOT_LIKE:
throw new IllegalArgumentException("Value must not be null for operator " + op);
default: // ok
}
} else if (value instanceof List<?> list) {
switch (op) {
case IN, NOT_IN:
break;
default:
throw new IllegalArgumentException("Value must not be a list for operator " + op);
}
if (list.isEmpty()) {
throw new IllegalArgumentException("List value must not be empty");
}
list.forEach(listElement -> {
if (notValid(listElement)) {
throw new IllegalArgumentException("List type value must be String or Number");
}
});
} else if (notValid(value)) {
throw new IllegalArgumentException(
"Value must be String, Number, Boolean, non-empty String list, non-empty Number list or null");
}
this.key = key.trim();
this.op = op;
this.value = value;
this.context = context;
}
@Override
public String toString() {
return key + " " + op + " " + valueToString(value);
}
private static String valueToString(final Object value) {
if (value == null) {
return "null";
} else if (value instanceof String) {
return String.format("\"%s\"", value);
} else if (value instanceof List<?> list) {
return "(" + list.stream()
.map(Comparison::valueToString)
.reduce((a, b) -> a + ", " + b)
.orElse("") + ")";
} else {
return value.toString();
}
}
private static boolean notValid(final Object value) {
return !(value instanceof String) && !(value instanceof Number) && !(value instanceof Boolean);
}
}
}

View File

@@ -0,0 +1,489 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.ql;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NOT_LIKE;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import jakarta.annotation.Nonnull;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.MapJoin;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.MapAttribute;
import jakarta.persistence.metamodel.PluralAttribute;
import jakarta.persistence.metamodel.SetAttribute;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison;
import org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator;
import org.eclipse.hawkbit.repository.jpa.ql.Node.Logical;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.util.ObjectUtils;
@Slf4j
public class SpecificationBuilder<T> {
private static final char ESCAPE_CHAR = '\\';
private final VirtualPropertyReplacer virtualPropertyReplacer;
private final boolean ensureIgnoreCase;
private final Database database;
public SpecificationBuilder(
final VirtualPropertyReplacer virtualPropertyReplacer,
final boolean ensureIgnoreCase, final Database database) {
this.virtualPropertyReplacer = virtualPropertyReplacer;
this.ensureIgnoreCase = ensureIgnoreCase;
this.database = database;
}
public Specification<T> specification(final Node node) {
return (root, query, cb) -> {
Objects.requireNonNull(query).distinct(true);
return new PredicateBuilder(root, query, cb).build(node);
};
}
private class PredicateBuilder {
private static final char LIKE_WILDCARD = '*';
private static final String LIKE_WILDCARD_STR = String.valueOf(LIKE_WILDCARD);
private static final String ESCAPE_CHAR_WITH_ASTERISK = ESCAPE_CHAR + LIKE_WILDCARD_STR;
private static final Predicate[] PREDICATES_ARRAY_0 = new Predicate[0];
private static final List<Object> NULL_VALUE;
static {
final List<Object> nullList = new ArrayList<>(1);
nullList.add(null);
NULL_VALUE = Collections.unmodifiableList(nullList);
}
private final Root<T> root;
private final CriteriaQuery<?> query;
private final CriteriaBuilder cb;
private final PathResolver pathResolver = new PathResolver();
private PredicateBuilder(final Root<T> root, final CriteriaQuery<?> query, final CriteriaBuilder cb) {
this.root = root;
this.query = query;
this.cb = cb;
}
public Predicate build(final Node node) {
if (node instanceof Comparison comparison) {
return predicate(comparison);
} else if (node instanceof Logical logical) {
final Logical.Operator op = Objects.requireNonNull(logical.getOp());
if (op == Logical.Operator.AND) {
return cb.and(logical.getChildren().stream()
.map(this::build)
.toList()
.toArray(PREDICATES_ARRAY_0));
} else if (op == Logical.Operator.OR) {
return cb.or(logical.getChildren().stream()
.map(child -> {
pathResolver.reset();
return build(child); // for or path resolver joins could be reused
})
.toList()
.toArray(PREDICATES_ARRAY_0));
} else {
throw new IllegalArgumentException("Unsupported logical operator: " + op);
}
} else {
throw new IllegalArgumentException("Unsupported node type: " + node.getClass());
}
}
@SuppressWarnings({ "unchecked", "java:S3776" }) // java:S3776 - easier to read at one place
private Predicate predicate(final Comparison comparison) {
final String[] split = comparison.getKey().split("\\.", 2); // { attributeName [, sub attribute / map key]
final Attribute<? super T, ?> attribute = root.getModel().getAttribute(split[0]);
final Operator op = comparison.getOp();
if (attribute instanceof MapAttribute<?, ?, ?>) {
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
throw new RSQLParameterUnsupportedFieldException(
String.format("No key for the map field found. Syntax is: %s.<key name>", getPathContext(comparison)));
}
if (comparison.getValue() == null) {
// map entry with key is null (doesn't exist) / is not null (exists) - use left join with on
return switch (op) {
case EQ -> cb.isNull(pathResolver.getJoinOn(attribute, split[1]));
case NE -> cb.isNotNull(pathResolver.getJoinOn(attribute, split[1]));
default -> throw new RSQLParameterSyntaxException(
String.format("Operator %s is not supported for map fields with value null", op));
};
} else {
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) pathResolver.getPath(attribute);
final Path<String> valuePath = (Path<String>) mapPath.value();
return isNot(op)
? compare(comparison, pathResolver.getJoinOnInner(attribute, split[1]))
: cb.and(equal(mapPath.key(), split[1]), compare(comparison, valuePath));
}
} else if (attribute instanceof SetAttribute<?, ?> setAttribute) {
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
throw new RSQLParameterUnsupportedFieldException(
String.format("No mapping key for a set field found. Syntax is: %s.<ref name>", getPathContext(comparison)));
}
if (isNot(op)) {
if (op == NOT_LIKE && LIKE_WILDCARD_STR.equals(comparison.getValue())) {
// optimized (?) has non-null/empty attribute - do or with on instead of subquery
return cb.and(cb.isNull(pathResolver.getPath(attribute).get(split[1])));
}
return notEqualInLike(comparison, setAttribute, split[1]);
} else {
if (op == LIKE && LIKE_WILDCARD_STR.equals(comparison.getValue())) {
// optimized (?) has non-null/empty attribute - do or with on instead of subquery
return cb.and(cb.isNotNull(deepGetPath(pathResolver.getPath(attribute), split[1])));
}
return compare(comparison, deepGetPath(pathResolver.getPath(attribute), split[1]));
}
} else { // singular attribute (BASIC and EMBEDDABLE) or plural (ListAttribute of entities)
final Path<?> attributePath = pathResolver.getPath(attribute);
return compare(comparison, split.length > 1 ? deepGetPath(attributePath, split[1]) : attributePath);
}
}
private Predicate compare(final Comparison comparison, final Path<?> fieldPath) {
final List<Object> values = getValues(comparison, fieldPath.getJavaType());
final Object firstValue = values.get(0);
return switch (comparison.getOp()) {
case EQ -> firstValue == null ? cb.isNull(fieldPath) : equal(fieldPath, firstValue);
case NE -> firstValue == null ? cb.isNotNull(fieldPath) : cb.or(cb.isNull(fieldPath), notEqual(fieldPath, firstValue));
case GT -> cb.greaterThan(stringPath(fieldPath), String.valueOf(firstValue)); // JPA handles numbers
case GTE -> cb.greaterThanOrEqualTo(stringPath(fieldPath), String.valueOf(firstValue));
case LT -> cb.lessThan(stringPath(fieldPath), String.valueOf(firstValue));
case LTE -> cb.lessThanOrEqualTo(stringPath(fieldPath), String.valueOf(firstValue));
case IN -> in(stringPath(fieldPath), values);
case NOT_IN -> cb.or(cb.isNull(fieldPath), cb.not(in(stringPath(fieldPath), values)));
case LIKE -> like(stringPath(fieldPath), toSqlLikeValue(String.valueOf(firstValue)));
case NOT_LIKE -> cb.or(cb.isNull(fieldPath), notLike(stringPath(fieldPath), toSqlLikeValue(String.valueOf(firstValue))));
};
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private Predicate notEqualInLike( // NE, NOT_IN, NOT_LIKE
final Comparison comparison, final PluralAttribute<?, ?, ?> pluralAttribute, final String subAttributeName) {
final List<Object> values = getValues(comparison, pluralAttribute.getElementType().getJavaType());
final Object value = values.get(0);
final EntityType<T> model = root.getModel();
if (!model.hasSingleIdAttribute()) {
throw new IllegalStateException("Root entity has no single id attribute");
}
final String idAttributeName = model.getId(model.getIdType().getJavaType()).getName();
final Class<? extends T> javaType = root.getJavaType();
final Subquery<? extends T> subquery = query.subquery(javaType);
final Root<? extends T> subqueryRoot = subquery.from(javaType);
final Path<?> joinPath = subAttributeName == null // if null it is a map
? ((MapJoin<?, ?, ?>) subqueryRoot.join(pluralAttribute.getName(), JoinType.LEFT)).value()
: deepGetPath(subqueryRoot.join(pluralAttribute.getName(), JoinType.LEFT), subAttributeName);
final Path<String> fieldPath = joinPath instanceof MapJoin<?, ?, ?> mapJoin
? (Path<String>) mapJoin.value()
: stringPath(joinPath);
return cb.not(cb.exists(
subquery.select((Expression) subqueryRoot)
.where(cb.and(
cb.equal(root.get(idAttributeName), subqueryRoot.get(idAttributeName)),
switch (comparison.getOp()) {
case NE -> equal(fieldPath, value);
case NOT_IN -> in(fieldPath, values);
case NOT_LIKE -> like(fieldPath, toSqlLikeValue((String) value));
default -> throw new IllegalStateException("Uncovered flow. Operator: " + comparison.getOp());
}))));
}
// java:S1066 - easier to understand separately
// java:S3776, java:S3358 - easier to read at one place
@SuppressWarnings({ "java:S1066", "java:S3776", "java:S3358" })
private List<Object> getValues(final Comparison comparison, final Class<?> javaType) {
final Object value = comparison.getValue();
final List<Object> values = (value == null ? NULL_VALUE : (value instanceof List<?> list ? list : List.of(value))).stream()
// if lookup is available, replace macros ...
.map(element -> virtualPropertyReplacer != null && element instanceof String strElement
? virtualPropertyReplacer.replace(strElement)
: element)
// converts value to the correct type
.map(element -> element instanceof String strElement
? convertValueIfNecessary(strElement, javaType, comparison)
: element)
.toList();
if (values.isEmpty()) {
throw new RSQLParameterSyntaxException("RSQL values must not be empty", null);
} else if (values.size() == 1) {
final Operator op = comparison.getOp();
// enum, boolean or null - doesn't support >, >=, <, <=
if (!(values.get(0) instanceof String)) {
if (values.get(0) instanceof Number) {
if (op == LIKE || op == NOT_LIKE) {
throw new RSQLParameterSyntaxException(op + " operator could not be applied number", null);
}
} else if (op == GT || op == GTE || op == LT || op == LTE || op == LIKE || op == NOT_LIKE) {
final String errorMsg = values.get(0) == null ? "null value" : "enum or boolean field";
throw new RSQLParameterSyntaxException(op + " operator could not be applied to " + errorMsg, null);
}
}
} else {
final Operator op = comparison.getOp();
if (op != IN && op != NOT_IN) {
throw new RSQLParameterSyntaxException(op + " operator shall have exactly one value", null);
}
}
return values;
}
// result is String, enum value, boolean or null
private Object convertValueIfNecessary(final String value, final Class<?> javaType, final Comparison comparison) {
if (javaType != null && javaType.isEnum()) {
return toEnumValue(value, javaType, comparison);
}
if (boolean.class.equals(javaType) || Boolean.class.equals(javaType)) {
if ("true".equals(value) || "false".equals(value)) {
return Boolean.valueOf(value);
} else {
throw new RSQLParameterSyntaxException(
String.format(
"The value of %S is not well formed. Only a boolean (true or false) value will be expected",
getPathContext(comparison)));
}
}
return value;
}
@Nonnull
private static Object getPathContext(final Comparison comparison) {
return comparison.getContext() == null ? comparison.getKey() : comparison.getContext();
}
private static boolean isNot(final Operator op) {
return op == NE || op == NOT_IN || op == NOT_LIKE;
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private static Object toEnumValue(final String value, final Class<?> javaType, final Comparison comparison) {
final Class<? extends Enum> tmpEnumType = (Class<? extends Enum>) javaType;
try {
return Enum.valueOf(tmpEnumType, value.toUpperCase());
} catch (final IllegalArgumentException e) {
// we could not transform the given string value into the enum type, so ignore it and return null and do not filter
if (log.isInfoEnabled()) {
log.info("Provided value '{}' cannot be transformed into the correct enum type {}", value.toUpperCase(), javaType);
} else {
log.debug("Provided value '{}' cannot be transformed into the correct enum type {}", value.toUpperCase(), javaType, e);
}
throw new RSQLParameterUnsupportedFieldException(
String.format(
"Value of %s must be one of the following: %s",
getPathContext(comparison),
Arrays.stream(tmpEnumType.getEnumConstants())
.map(Enum::name)
.map(String::toLowerCase)
.toList()),
e);
}
}
private static Path<?> deepGetPath(final Path<?> path, final String subAttributeName) {
return deepGetPath(path, subAttributeName.split("\\."), 0);
}
private static Path<?> deepGetPath(final Path<?> path, final String[] subAttributeNameSplit, int startIndex) {
final String subAttributeName = subAttributeNameSplit[startIndex++];
if (startIndex == subAttributeNameSplit.length) {
return path.get(subAttributeName);
} else { // else its a deeper path so request left join
if (path instanceof Join<?,?> join) {
return deepGetPath(join.join(subAttributeName, JoinType.LEFT), subAttributeNameSplit, startIndex);
} else {
throw new RSQLParameterSyntaxException("Unexpected sub attribute " + subAttributeName);
}
}
}
@SuppressWarnings("unchecked")
private static Path<String> stringPath(final Path<?> path) {
return (Path<String>) path;
}
private String toSqlLikeValue(final String value) {
final String escaped;
if (database == Database.SQL_SERVER) {
escaped = value.replace("%", "[%]").replace("_", "[_]");
} else {
escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
}
final String finalizedValue;
if (escaped.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
finalizedValue = escaped.replace(ESCAPE_CHAR_WITH_ASTERISK, "$")
.replace(LIKE_WILDCARD, '%')
.replace("$", ESCAPE_CHAR_WITH_ASTERISK);
} else {
finalizedValue = escaped.replace(LIKE_WILDCARD, '%');
}
return finalizedValue;
}
@SuppressWarnings("java:S1221") // java:S1221 - intentionally to match the SQL wording
private Predicate equal(final Path<?> fieldPath, final Object value) {
if (value instanceof String valueStr && caseWise(fieldPath)) {
return cb.equal(cb.upper(stringPath(fieldPath)), valueStr.toUpperCase());
} else {
return cb.equal(fieldPath, value);
}
}
private Predicate notEqual(final Path<?> fieldPath, Object value) {
if (value instanceof String valueStr && caseWise(fieldPath)) {
return cb.notEqual(cb.upper(stringPath(fieldPath)), valueStr.toUpperCase());
} else {
return cb.notEqual(fieldPath, value);
}
}
private Predicate like(final Path<String> fieldPath, final String sqlValue) {
if (caseWise(fieldPath)) {
return cb.like(cb.upper(fieldPath), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.like(fieldPath, sqlValue, ESCAPE_CHAR);
}
}
private Predicate notLike(final Path<String> fieldPath, final String sqlValue) {
if (caseWise(fieldPath)) {
return cb.notLike(cb.upper(fieldPath), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.notLike(fieldPath, sqlValue, ESCAPE_CHAR);
}
}
private Predicate in(final Path<String> fieldPath, final List<Object> values) {
if (caseWise(fieldPath)) {
final List<String> inParams = values.stream()
.filter(String.class::isInstance)
.map(String.class::cast).map(String::toUpperCase).toList();
return inParams.isEmpty() ? fieldPath.in(values) : cb.upper(fieldPath).in(inParams);
} else {
return fieldPath.in(values);
}
}
private boolean caseWise(final Path<?> fieldPath) {
return ensureIgnoreCase && fieldPath.getJavaType() == String.class;
}
private class PathResolver {
private final Map<String, CollectionPathResolver> attributeToPathResolver = new HashMap<>();
private Path<?> getPath(final Attribute<? super T, ?> attribute) {
return switch (attribute.getPersistentAttributeType()) {
case BASIC -> root.get(attribute.getName());
case MANY_TO_ONE, ONE_TO_ONE -> root.getJoins().stream()
.filter(join -> join.getAttribute().equals(attribute))
.findFirst()
.orElseGet(() -> root.join(attribute.getName(), JoinType.LEFT));
case MANY_TO_MANY, ONE_TO_MANY, ELEMENT_COLLECTION -> getCollectionPathResolver(attribute.getName()).getPath();
default -> throw new IllegalArgumentException("Unsupported attribute type: " + attribute.getPersistentAttributeType());
};
}
private MapJoin<?, ?, ?> getJoinOn(final Attribute<?, ?> attribute, final Object value) {
return getCollectionPathResolver(attribute.getName()).getJoinOn(value);
}
private MapJoin<?, ?, ?> getJoinOnInner(final Attribute<?, ?> attribute, final Object value) {
return getCollectionPathResolver(attribute.getName()).getJoinOnInner(value);
}
private void reset() {
attributeToPathResolver.values().forEach(CollectionPathResolver::reset);
}
@Nonnull
private CollectionPathResolver getCollectionPathResolver(final String attributeName) {
return attributeToPathResolver.computeIfAbsent(attributeName, CollectionPathResolver::new);
}
private class CollectionPathResolver {
private final String attributeName;
private final List<Path<?>> paths = new ArrayList<>();
private int pos;
private final Map<Object, MapJoin<?, ?, ?>> joinOnCache = new HashMap<>();
private final Map<Object, MapJoin<?, ?, ?>> joinOnInnerCache = new HashMap<>();
private CollectionPathResolver(final String attributeName) {
this.attributeName = attributeName;
}
private Path<?> getPath() {
if (pos < paths.size()) {
return paths.get(pos++);
} else {
final Path<?> path = root.join(attributeName, JoinType.LEFT);
paths.add(path);
pos++;
return path;
}
}
private MapJoin<?, ?, ?> getJoinOn(final Object value) {
return joinOnCache.computeIfAbsent(value, k -> {
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) root.join(attributeName, JoinType.LEFT);
mapPath.on(equal(mapPath.key(), k));
return mapPath;
});
}
private MapJoin<?, ?, ?> getJoinOnInner(final Object value) {
return joinOnInnerCache.computeIfAbsent(value, k -> {
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) root.join(attributeName, JoinType.INNER);
mapPath.on(equal(mapPath.key(), k));
return mapPath;
});
}
private void reset() {
pos = 0;
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.ql.utils;
import java.lang.reflect.InvocationTargetException;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import jakarta.persistence.TypedQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlUtility;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder;
import org.springframework.orm.jpa.vendor.Database;
public class HawkbitQlToSql {
private static final Database DATABASE = Database.H2;
private final EntityManager entityManager;
private final boolean isEclipselink;
public HawkbitQlToSql(final EntityManager entityManager) {
this.entityManager = entityManager;
isEclipselink = entityManager.getProperties().keySet().stream().anyMatch(key -> key.startsWith("eclipselink."));
}
public <T, A extends Enum<A> & RsqlQueryField> String toSQL(
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql, final RsqlToSpecBuilder rsqlToSpecBuilder) {
final CriteriaQuery<T> query = createQuery(domainClass, fieldsClass, rsql, rsqlToSpecBuilder);
final TypedQuery<?> typedQuery = entityManager.createQuery(query);
if (isEclipselink) {
try {
return (String)Class.forName("org.eclipse.hawkbit.repository.jpa.EclipselinkUtils")
.getMethod("toSql", Query.class)
.invoke(null, typedQuery);
} catch (final IllegalAccessException | NoSuchMethodException | ClassNotFoundException e) {
throw new IllegalStateException(e);
} catch (final InvocationTargetException e) {
if (e.getCause() instanceof RuntimeException re) {
throw re;
} else {
throw new IllegalStateException(e.getCause());
}
}
} else {
return HibernateUtils.toSql(typedQuery);
}
}
private <T, A extends Enum<A> & RsqlQueryField> CriteriaQuery<T> createQuery(
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql, final RsqlToSpecBuilder rsqlToSpecBuilder) {
final CriteriaQuery<T> query = entityManager.getCriteriaBuilder().createQuery(domainClass);
final CriteriaBuilder cb = entityManager.getCriteriaBuilder();
final RsqlToSpecBuilder defaultRsqlToSpecBuilder = RsqlConfigHolder.getInstance().getRsqlToSpecBuilder();
if (defaultRsqlToSpecBuilder != rsqlToSpecBuilder) {
RsqlConfigHolder.getInstance().setRsqlToSpecBuilder(rsqlToSpecBuilder);
}
try {
return query.where(RsqlUtility.<A, T> buildRsqlSpecification(rsql, fieldsClass, null, DATABASE)
.toPredicate(query.from(domainClass), cb.createQuery(domainClass), cb));
} finally {
if (defaultRsqlToSpecBuilder != rsqlToSpecBuilder) {
RsqlConfigHolder.getInstance().setRsqlToSpecBuilder(defaultRsqlToSpecBuilder);
}
}
}
}

View File

@@ -0,0 +1,106 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.ql.utils;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import jakarta.persistence.Query;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.engine.spi.SessionFactoryImplementor;
import org.hibernate.engine.spi.SharedSessionContractImplementor;
import org.hibernate.metamodel.mapping.MappingModelExpressible;
import org.hibernate.query.spi.QueryEngine;
import org.hibernate.query.spi.QueryParameterImplementor;
import org.hibernate.query.sqm.internal.QuerySqmImpl;
import org.hibernate.query.sqm.internal.SqmUtil;
import org.hibernate.query.sqm.spi.SqmParameterMappingModelResolutionAccess;
import org.hibernate.query.sqm.sql.SqmTranslation;
import org.hibernate.query.sqm.sql.SqmTranslator;
import org.hibernate.query.sqm.sql.SqmTranslatorFactory;
import org.hibernate.query.sqm.tree.SqmDmlStatement;
import org.hibernate.query.sqm.tree.expression.SqmParameter;
import org.hibernate.query.sqm.tree.select.SqmSelectStatement;
import org.hibernate.sql.ast.SqlAstTranslatorFactory;
import org.hibernate.sql.ast.tree.MutationStatement;
import org.hibernate.sql.ast.tree.Statement;
import org.hibernate.sql.ast.tree.select.SelectStatement;
import org.hibernate.sql.exec.spi.JdbcParameterBindings;
import org.hibernate.sql.exec.spi.JdbcParametersList;
@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
@Slf4j
public class HibernateUtils {
private static final Method getSqmTranslatorFactory;
static {
Method method = null;
try {
method = QueryEngine.class.getMethod("getSqmTranslatorFactory");
} catch (final NoSuchMethodException e) {
log.warn("Can't resolve getSqmTranslatorFactory method (Utils.toString won't work)", e);
}
getSqmTranslatorFactory = method;
}
public static String toSql(final Query query) {
if (getSqmTranslatorFactory == null) {
throw new UnsupportedOperationException("SqmTranslatorFactory resolver is not available");
}
final QuerySqmImpl<?> hqlQuery = query.unwrap(QuerySqmImpl.class);
final SessionFactoryImplementor factory = hqlQuery.getSessionFactory();
final SharedSessionContractImplementor session = hqlQuery.getSession();
final SessionFactoryImplementor sessionFactory = session.getFactory();
final SqmTranslatorFactory sqmTranslatorFactory;
try {
sqmTranslatorFactory = (SqmTranslatorFactory) getSqmTranslatorFactory.invoke(factory.getQueryEngine());
} catch (final IllegalAccessException | InvocationTargetException e) {
throw new UnsupportedOperationException("Can't create SqmTranslatorFactory", e);
}
final SqmTranslator<? extends Statement> sqmSelectTranslator =
hqlQuery.getSqmStatement() instanceof SqmSelectStatement<?> selectStatement
? sqmTranslatorFactory.createSelectTranslator(selectStatement,
hqlQuery.getQueryOptions(), hqlQuery.getDomainParameterXref(), hqlQuery.getQueryParameterBindings(),
hqlQuery.getLoadQueryInfluencers(), sessionFactory, false)
: sqmTranslatorFactory.createMutationTranslator((SqmDmlStatement<?>) hqlQuery.getSqmStatement(),
hqlQuery.getQueryOptions(), hqlQuery.getDomainParameterXref(), hqlQuery.getQueryParameterBindings(),
hqlQuery.getLoadQueryInfluencers(), sessionFactory);
final SqmTranslation<? extends Statement> sqmTranslation = sqmSelectTranslator.translate();
final SqlAstTranslatorFactory sqlAstTranslatorFactory = factory.getJdbcServices().getJdbcEnvironment().getSqlAstTranslatorFactory();
final Map<QueryParameterImplementor<?>, Map<SqmParameter<?>, List<JdbcParametersList>>> jdbcParamsXref = SqmUtil.generateJdbcParamsXref(
hqlQuery.getDomainParameterXref(), sqmTranslation::getJdbcParamsBySqmParam);
final JdbcParameterBindings jdbcParameterBindings = SqmUtil.createJdbcParameterBindings(hqlQuery.getQueryParameterBindings(),
hqlQuery.getDomainParameterXref(), jdbcParamsXref, factory.getRuntimeMetamodels().getMappingMetamodel(),
sqmSelectTranslator.getFromClauseAccess()::findTableGroup, new SqmParameterMappingModelResolutionAccess() {
@Override
@SuppressWarnings("unchecked")
public <T> MappingModelExpressible<T> getResolvedMappingModelType(final SqmParameter<T> parameter) {
return (MappingModelExpressible<T>) sqmTranslation.getSqmParameterMappingModelTypeResolutions().get(parameter);
}
}, hqlQuery.getSession());
return (sqmTranslation.getSqlAst() instanceof SelectStatement selectStatement
? sqlAstTranslatorFactory.buildSelectTranslator(factory, selectStatement)
.translate(jdbcParameterBindings, hqlQuery.getQueryOptions())
: sqlAstTranslatorFactory.buildMutationTranslator(factory, (MutationStatement) sqmTranslation.getSqlAst())
.translate(jdbcParameterBindings, hqlQuery.getQueryOptions()))
.getSqlString();
}
}

View File

@@ -0,0 +1,61 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Value;
/**
* Helper class providing static access to the RSQL configuration as managed bean.
*/
@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
@Getter
@SuppressWarnings("java:S6548") // singleton holder ensures static access to spring resources in some places
public final class RsqlConfigHolder {
private static final RsqlConfigHolder SINGLETON = new RsqlConfigHolder();
public enum RsqlToSpecBuilder {
LEGACY_G1, // legacy RSQL visitor
LEGACY_G2, // G2 RSQL visitor
G3 // G3 RSQL visitor - still experimental / yet default
}
/**
* If RSQL comparison operators shall ignore the case. If ignore case is <code>true</code> "x == ax" will match "x == aX"
*/
@Value("${hawkbit.rsql.ignore-case:true}")
private boolean ignoreCase;
/**
* Declares if the database is case-insensitive, by default assumes <code>false</code>. In case it is case-sensitive and,
* {@link #ignoreCase} is set to <code>true</code> the SQL queries use upper case comparisons to ignore case.
*
* If the database is declared as case-sensitive and ignoreCase is set to <code>false</code> the RSQL queries shall use strict
* syntax - i.e. 'and' instead of 'AND' / 'aND'. Otherwise, the queries would be case-insensitive regarding operators.
*/
@Value("${hawkbit.rsql.case-insensitive-db:false}")
private boolean caseInsensitiveDB;
/**
* @deprecated in favour fixed final visitor / spec builder of G2 RSQL visitor / G3 spec builder. since 0.6.0
*/
@Setter // for tests only
@Deprecated(forRemoval = true, since = "0.6.0")
@Value("${hawkbit.rsql.rsql-to-spec-builder:G3}") //
private RsqlToSpecBuilder rsqlToSpecBuilder;
/**
* @return The holder singleton instance.
*/
public static RsqlConfigHolder getInstance() {
return SINGLETON;
}
}

View File

@@ -0,0 +1,271 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
import static org.eclipse.hawkbit.repository.RsqlQueryField.SUB_ATTRIBUTE_SEPARATOR;
import static org.eclipse.hawkbit.repository.RsqlQueryField.SUB_ATTRIBUTE_SPLIT_REGEX;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.EQ;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison.Operator.NOT_LIKE;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import jakarta.annotation.Nullable;
import cz.jirutka.rsql.parser.RSQLParser;
import cz.jirutka.rsql.parser.RSQLParserException;
import cz.jirutka.rsql.parser.ast.AndNode;
import cz.jirutka.rsql.parser.ast.ComparisonNode;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import cz.jirutka.rsql.parser.ast.OrNode;
import cz.jirutka.rsql.parser.ast.RSQLOperators;
import cz.jirutka.rsql.parser.ast.RSQLVisitor;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.FieldValueConverter;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.ql.Node;
import org.eclipse.hawkbit.repository.jpa.ql.Node.Comparison;
/**
* {@link RsqlParser} parses RSQL query stings to {@link Node} objects. Doing that it does the following:
* <ul>
* <li>check for null value and pass it properly (IS, NOT)</li>
* <li>check for * and convert EQ/NEQ to LIKE/NOT_LIKE</li>
* <li>replace the rsql fields (enum values) with the JPA entity field names</li>
* <li>checks sub-attributes (if allowed)</li>
* <li>append the default sub-attributes if needed)</li>
* <li>apply value conversion for implementing with FieldValueConverter</li>
* </ul>
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Slf4j
public class RsqlParser {
public static final Character LIKE_WILDCARD = '*';
public static final ComparisonOperator IS = new ComparisonOperator("=is=", "=eq=");
public static final ComparisonOperator NOT = new ComparisonOperator("=not=", "=ne=");
private static final RSQLParser PARSER;
static {
final Set<ComparisonOperator> operators = new HashSet<>(RSQLOperators.defaultOperators());
// == and != alternatives just treating "null" string as null not as a "null"
operators.add(IS);
operators.add(NOT);
PARSER = new RSQLParser(operators);
}
public static Node parse(final String rsql) {
return parse(rsql, (Function<String, Key>) null);
}
public static <T extends Enum<T> & RsqlQueryField> Node parse(final String rsql, final Class<T> rsqlQueryFieldType) {
return parse(rsql, rsqlQueryFieldType == null ? null : key -> resolveKey(key, rsqlQueryFieldType));
}
private static Node parse(final String rsql, final Function<String, Key> keyResolver) {
try {
return PARSER
.parse(rsql)
.accept(new RsqlVisitor(keyResolver));
} catch (final RSQLParserException e) {
throw new RSQLParameterSyntaxException(e);
}
}
@SuppressWarnings("java:S3776") // java:S3776 - group in single method for easier read of whole logic
private static <T extends Enum<T> & RsqlQueryField> Key resolveKey(final String key, final Class<T> rsqlQueryFieldType) {
final int firstSeparatorIndex = key.indexOf(SUB_ATTRIBUTE_SEPARATOR);
final String enumName = (firstSeparatorIndex == -1 ? key : key.substring(0, firstSeparatorIndex)).toUpperCase();
log.debug("Get field identifier by name {} of enum type {}", enumName, rsqlQueryFieldType);
final T enumValue;
try {
enumValue = Enum.valueOf(rsqlQueryFieldType, enumName);
} catch (final IllegalArgumentException e) {
throw new RSQLParameterUnsupportedFieldException(e);
}
final String attribute;
if (firstSeparatorIndex == -1) { // just field name without sub-attribute
if (enumValue.getSubEntityAttributes().isEmpty()) {
// no sub-attributes -> simple field
attribute = enumValue.getJpaEntityFieldName();
} else {
// just enum name for a complex type (with sub-attributes), should have single (default!) sub-attribute
if (enumValue.isMap()) {
throw new RSQLParameterUnsupportedFieldException("No key specified for a map type " + enumValue);
} else if (enumValue.getSubEntityAttributes().size() == 1) {
// single sub attribute - so, treat it as a default
attribute = enumValue.getJpaEntityFieldName() + SUB_ATTRIBUTE_SEPARATOR + enumValue.getSubEntityAttributes().get(0);
} else {
throw new RSQLParameterUnsupportedFieldException(
String.format(
"The given search parameter field {%s} requires one of the following sub-attributes %s",
key, enumValue.getSubEntityAttributes()));
}
}
} else { // field name with sub-attribute
if (enumValue.isMap()) {
// map, the part after the enum name is the key of the map
attribute = enumValue.getJpaEntityFieldName() + SUB_ATTRIBUTE_SEPARATOR + key.substring(firstSeparatorIndex + 1);
} else if (enumValue.getSubEntityAttributes().isEmpty()) {
// simple type without sub-attributes, so the sub-attribute is not allowed
throw new RSQLParameterUnsupportedFieldException("Sub-attributes not supported for simple field " + enumValue);
} else {
final String[] subAttribute = key.substring(firstSeparatorIndex + 1).split(SUB_ATTRIBUTE_SPLIT_REGEX, 2);
attribute = enumValue.getJpaEntityFieldName() + SUB_ATTRIBUTE_SEPARATOR + enumValue.getSubEntityAttributes().stream()
.filter(attr -> attr.equalsIgnoreCase(subAttribute[0])) // case normalized
.findFirst()
.map(attr -> subAttribute.length == 1 ? attr : attr + key.substring(firstSeparatorIndex + 1 + attr.length()))
.orElseThrow(() -> new RSQLParameterUnsupportedFieldException(
String.format(
"The given search field {%s} has unsupported sub-attributes. Supported sub-attributes are %s",
key, enumValue.getSubEntityAttributes())));
}
}
return new Key(attribute, RsqlVisitor.valueConverter(enumValue));
}
private record Key(String path, UnaryOperator<Object> converter) {}
private static class RsqlVisitor implements RSQLVisitor<Node, String> {
private final Function<String, Key> keyResolver;
private RsqlVisitor(final Function<String, Key> keyResolver) {
this.keyResolver = keyResolver == null ? str -> new Key(str, UnaryOperator.identity()) : keyResolver;
}
@Override
public Node visit(final AndNode node, final String param) {
return node.getChildren().stream()
.map(child -> child.accept(this))
.reduce(Node::and)
.orElseThrow();
}
@Override
public Node visit(final OrNode node, final String param) {
return node.getChildren().stream()
.map(child -> child.accept(this))
.reduce(Node::or)
.orElseThrow();
}
@SuppressWarnings("java:S3776") // java:S3776 - group in single method for easier read of whole logic
@Override
public Node visit(final ComparisonNode node, final String param) {
final String nodeSelector = node.getSelector();
final Key key = keyResolver.apply(nodeSelector);
final String path = key.path();
final Object value = toValue(node, key);
final ComparisonOperator op = node.getOperator();
if (op == IS || op == RSQLOperators.EQUAL) {
if ("".equals(value)) {
// keep special backward compatible behaviour tags == '' means "null / has not or ''
return new Comparison(path, EQ, null, nodeSelector).or(new Comparison(path, EQ, "", nodeSelector));
}
return new Comparison(path, isLike(value) ? LIKE : EQ, value, nodeSelector);
} else if (op == NOT || op == RSQLOperators.NOT_EQUAL) {
if ("".equals(value)) {
// keep special backward compatible behaviour. != '' means "not null / has and not ''
return new Comparison(path, LIKE, "*", nodeSelector).and(new Comparison(path, NE, "", nodeSelector));
}
return new Comparison(path, isLike(value) ? NOT_LIKE : NE, value, nodeSelector);
} else if (op == RSQLOperators.GREATER_THAN) {
return new Comparison(path, GT, value, nodeSelector);
} else if (op == RSQLOperators.GREATER_THAN_OR_EQUAL) {
return new Comparison(path, GTE, value, nodeSelector);
} else if (op == RSQLOperators.LESS_THAN) {
return new Comparison(path, LT, value, nodeSelector);
} else if (op == RSQLOperators.LESS_THAN_OR_EQUAL) {
return new Comparison(path, LTE, value, nodeSelector);
} else if (op == RSQLOperators.IN) {
return new Comparison(path, IN, value, nodeSelector);
} else if (op == RSQLOperators.NOT_IN) {
return new Comparison(path, NOT_IN, value, nodeSelector);
} else {
throw new IllegalArgumentException("Unsupported operator: " + node.getOperator());
}
}
private Object toValue(final ComparisonNode node, final Key key) {
final List<String> arguments = node.getArguments();
final ComparisonOperator operator = node.getOperator();
if (arguments.isEmpty()) {
throw new IllegalArgumentException("Operator " + operator + " requires at least one argument");
}
if (arguments.size() == 1 && "null".equals(arguments.get(0)) && (operator == IS || operator == NOT)) {
return null;
} else {
if (operator == RSQLOperators.IN || operator == RSQLOperators.NOT_IN) {
return arguments.stream().map(key.converter()).toList();
} else if (arguments.size() > 1) {
throw new IllegalArgumentException(
"Operator " + operator + " requires exactly one argument, but got: " + arguments.size());
} else {
return key.converter().apply(arguments.get(0));
}
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private static <T extends Enum<T>> UnaryOperator<Object> valueConverter(@Nullable final T enumValue) {
if (enumValue instanceof FieldValueConverter fieldValueConverter) {
return value -> {
if (value instanceof String strValue) {
try {
return fieldValueConverter.convertValue(enumValue, strValue);
} catch (final Exception e) {
throw new RSQLParameterUnsupportedFieldException(e.getMessage(), null);
}
} else {
return value;
}
};
} else {
return UnaryOperator.identity();
}
}
private static final String ESCAPE_CHAR_WITH_ASTERISK = "\\" + LIKE_WILDCARD;
private static boolean isLike(final Object value) {
if (value instanceof String valueStr) {
if (valueStr.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
return valueStr.replace(ESCAPE_CHAR_WITH_ASTERISK, "$").indexOf(LIKE_WILDCARD) != -1;
} else {
return valueStr.indexOf(LIKE_WILDCARD) != -1;
}
} else {
return false;
}
}
}
}

View File

@@ -0,0 +1,118 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql;
import static org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder.G3;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import cz.jirutka.rsql.parser.RSQLParserException;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.text.StrLookup;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.ql.SpecificationBuilder;
import org.eclipse.hawkbit.repository.jpa.rsql.legacy.SpecificationBuilderLegacy;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyResolver;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.orm.jpa.vendor.Database;
/**
* A utility class which is able to parse RSQL strings into an spring data
* {@link Specification} which then can be enhanced sql queries to filter
* entities. RSQL parser library: https://github.com/jirutka/rsql-parser
*
* <ul>
* <li>{@code Equal to : ==}</li>
* <li>{@code Not equal to : !=}</li>
* <li>{@code Less than : =lt= or <}</li>
* <li>{@code Less than or equal to : =le= or <=}</li>
* <li>{@code Greater than operator : =gt= or >}</li>
* <li>{@code Greater than or equal to : =ge= or >=}</li>
* </ul>
* <p>
* Examples of RSQL expressions in both FIQL-like and alternative notation:
* <ul>
* <li>{@code version==2.0.0}</li>
* <li>{@code name==targetId1;description==plugAndPlay}</li>
* <li>{@code name==targetId1 and description==plugAndPlay}</li>
* <li>{@code name==targetId1,description==plugAndPlay,updateStatus==UNKNOWN}</li>
* <li>{@code name==targetId1 or description==plugAndPlay or updateStatus==UNKNOWN}</li>
* </ul>
* <p>
* There is also a mechanism that allows to refer to known macros that can
* resolved by an optional {@link StrLookup} (cp.
* {@link VirtualPropertyResolver}).<br>
* An example that queries for all overdue targets using the ${OVERDUE_TS}
* placeholder introduced by {@link VirtualPropertyResolver} looks like
* this:<br>
* <em>lastControllerRequestAt=le=${OVERDUE_TS}</em><br>
* It is possible to escape a macro expression by using a second '$':
* $${OVERDUE_TS} would prevent the ${OVERDUE_TS} token from being expanded.
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class RsqlUtility {
/**
* Builds a JPA {@link Specification} which corresponds with the given RSQL query. The specification can be used to filter for JPA entities
* with the given RSQL query.
*
* @param rsql the rsql query to be parsed
* @param rsqlQueryFieldType the enum class type which implements the {@link RsqlQueryField}
* @param virtualPropertyReplacer holds the logic how the known macros have to be resolved; may be <code>null</code>
* @param database database in use
* @return a specification which can be used with JPA
* @throws RSQLParameterUnsupportedFieldException if a field in the RSQL string is used but not provided by the
* given {@code fieldNameProvider}
* @throws RSQLParameterSyntaxException if the RSQL syntax is wrong
*/
public static <A extends Enum<A> & RsqlQueryField, T> Specification<T> buildRsqlSpecification(
final String rsql, final Class<A> rsqlQueryFieldType,
final VirtualPropertyReplacer virtualPropertyReplacer, final Database database) {
if (RsqlConfigHolder.getInstance().getRsqlToSpecBuilder() == G3) {
return new SpecificationBuilder<T>(
virtualPropertyReplacer,
!RsqlConfigHolder.getInstance().isCaseInsensitiveDB() && RsqlConfigHolder.getInstance().isIgnoreCase(),
database)
.specification(RsqlParser.parse(
RsqlConfigHolder.getInstance().isCaseInsensitiveDB() || RsqlConfigHolder.getInstance().isIgnoreCase()
? rsql.toLowerCase() : rsql,
rsqlQueryFieldType));
} else {
return new SpecificationBuilderLegacy<A, T>(rsqlQueryFieldType, virtualPropertyReplacer, database).specification(rsql);
}
}
/**
* Validates the RSQL string
*
* @param rsql RSQL string to validate
* @param rsqlQueryFieldType
* @throws RSQLParserException if RSQL syntax is invalid
* @throws RSQLParameterUnsupportedFieldException if RSQL key is not allowed
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public static <A extends Enum<A> & RsqlQueryField> void validateRsqlFor(
final String rsql, final Class<A> rsqlQueryFieldType,
final Class<?> jpaType,
final VirtualPropertyReplacer virtualPropertyReplacer, final EntityManager entityManager) {
final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
final CriteriaQuery<?> criteriaQuery = criteriaBuilder.createQuery(jpaType);
buildRsqlSpecification(rsql, rsqlQueryFieldType, virtualPropertyReplacer, null)
.toPredicate(criteriaQuery.from((Class) jpaType), criteriaQuery, criteriaBuilder);
}
}

View File

@@ -0,0 +1,188 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.legacy;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import jakarta.validation.constraints.NotNull;
import cz.jirutka.rsql.parser.ast.ComparisonNode;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import cz.jirutka.rsql.parser.ast.RSQLOperators;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.ql.SpecificationBuilder;
import org.springframework.util.ObjectUtils;
/**
* @deprecated Old implementation of RSQL Visitor (G2). Deprecated in favour of next gen implementation - {@link SpecificationBuilder}.
*/
@Deprecated(forRemoval = true, since = "0.9.0")
@Slf4j
public abstract class AbstractRSQLVisitor<A extends Enum<A> & RsqlQueryField> {
static final ComparisonOperator IS = new ComparisonOperator("=is=", "=eq=");
static final ComparisonOperator NOT = new ComparisonOperator("=not=", "=ne=");
static final Set<ComparisonOperator> OPERATORS;
static {
final Set<ComparisonOperator> operators = new HashSet<>(RSQLOperators.defaultOperators());
// == and != alternatives just treating "null" string as null not as a "null"
operators.add(IS);
operators.add(NOT);
OPERATORS = Collections.unmodifiableSet(operators);
}
private final Class<A> rsqlQueryFieldType;
protected AbstractRSQLVisitor(final Class<A> rsqlQueryFieldType) {
this.rsqlQueryFieldType = rsqlQueryFieldType;
}
@SuppressWarnings("java:S1066") // java:S1066 - more readable with separate "if" statements
protected QueryPath getQueryPath(final ComparisonNode node) {
final int firstSeparatorIndex = node.getSelector().indexOf(RsqlQueryField.SUB_ATTRIBUTE_SEPARATOR);
final String enumName = (firstSeparatorIndex == -1
? node.getSelector()
: node.getSelector().substring(0, firstSeparatorIndex)).toUpperCase();
log.debug("Get field identifier by name {} of enum type {}", enumName, rsqlQueryFieldType);
try {
final A enumValue = Enum.valueOf(rsqlQueryFieldType, enumName);
String[] split = getSplit(enumValue, node.getSelector());
// validate
if (!enumValue.isMap()) {
// sub entity need minimum 1 dot
if (!enumValue.getSubEntityAttributes().isEmpty() && split.length < 2) {
if (enumValue.getSubEntityAttributes().size() == 1) { // single sub attribute - so add is as a default
split = new String[] { split[0], enumValue.getSubEntityAttributes().get(0) };
} else {
throw createRSQLParameterUnsupportedException(node, null);
}
}
}
// validate and normalize (replace enum name with JPA entity field name and format the rest) prepare jpa path
split[0] = enumValue.getJpaEntityFieldName();
for (int i = 1; i < split.length; i++) {
split[i] = getFormattedSubEntityAttribute(enumValue, split[i]);
if (!containsSubEntityAttribute(enumValue, split[i])) {
if (i != split.length - 1 || !enumValue.isMap()) {
throw createRSQLParameterUnsupportedException(node, null);
} // otherwise - the key of map is not in the sub entity attributes
}
}
return new QueryPath(enumValue, split);
} catch (final IllegalArgumentException e) {
throw createRSQLParameterUnsupportedException(node, e);
}
}
private String[] getSplit(final A enumValue, final String rsqlFieldName) {
if (enumValue.isMap()) {
final String[] split = rsqlFieldName.split(RsqlQueryField.SUB_ATTRIBUTE_SPLIT_REGEX, 2);
if (split.length != 2 || ObjectUtils.isEmpty(split[1])) {
throw new RSQLParameterUnsupportedFieldException(
"The syntax of the given map search parameter field {" + rsqlFieldName + "} is wrong. Syntax is: <enum name>.<key name>");
}
return split;
} else {
return rsqlFieldName.split(RsqlQueryField.SUB_ATTRIBUTE_SPLIT_REGEX);
}
}
/**
* Contains the sub entity the given field.
*
* @param propertyField the given field
* @return <code>true</code> contains <code>false</code> contains not
*/
private boolean containsSubEntityAttribute(final A enumField, final String propertyField) {
final List<String> subEntityAttributes = enumField.getSubEntityAttributes();
if (subEntityAttributes.contains(propertyField)) {
return true;
}
for (final String attribute : subEntityAttributes) {
final String[] graph = attribute.split(RsqlQueryField.SUB_ATTRIBUTE_SPLIT_REGEX);
for (final String subAttribute : graph) {
if (subAttribute.equalsIgnoreCase(propertyField)) {
return true;
}
}
}
return false;
}
/**
* @param node current processing node
* @param rootException in case there is a cause otherwise {@code null}
* @return Exception with prepared message extracted from the comparison node.
*/
private RSQLParameterUnsupportedFieldException createRSQLParameterUnsupportedException(
@NotNull final ComparisonNode node, final Exception rootException) {
return new RSQLParameterUnsupportedFieldException(String.format(
"The given search parameter field {%s} does not exist, must be one of the following fields %s",
node.getSelector(), getExpectedFieldList()), rootException);
}
private String getFormattedSubEntityAttribute(final A propertyEnum, final String propertyField) {
return propertyEnum.getSubEntityAttributes().stream()
.filter(attr -> attr.equalsIgnoreCase(propertyField))
.findFirst().orElse(propertyField);
}
private List<String> getExpectedFieldList() {
return Stream.concat(
Arrays.stream(rsqlQueryFieldType.getEnumConstants())
.filter(enumField -> enumField.getSubEntityAttributes().isEmpty()).map(enumField -> {
final String enumFieldName = enumField.name().toLowerCase();
if (enumField.isMap()) {
return enumFieldName + RsqlQueryField.SUB_ATTRIBUTE_SEPARATOR + "keyName";
} else {
return enumFieldName;
}
}),
Arrays.stream(rsqlQueryFieldType.getEnumConstants())
.filter(enumField -> !enumField.getSubEntityAttributes().isEmpty()).flatMap(enumField -> {
final List<String> subEntity = enumField
.getSubEntityAttributes().stream().map(fieldName -> enumField.name().toLowerCase()
+ RsqlQueryField.SUB_ATTRIBUTE_SEPARATOR + fieldName)
.toList();
return subEntity.stream();
}))
.toList();
}
@Value
protected class QueryPath {
A enumValue;
String[] jpaPath;
private QueryPath(final A enumValue, final String[] jpaPath) {
this.enumValue = enumValue;
this.jpaPath = jpaPath;
}
}
}

View File

@@ -0,0 +1,576 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.legacy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.From;
import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.MapJoin;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.PluralJoin;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import cz.jirutka.rsql.parser.ast.AndNode;
import cz.jirutka.rsql.parser.ast.ComparisonNode;
import cz.jirutka.rsql.parser.ast.LogicalNode;
import cz.jirutka.rsql.parser.ast.Node;
import cz.jirutka.rsql.parser.ast.OrNode;
import cz.jirutka.rsql.parser.ast.RSQLVisitor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.math.NumberUtils;
import org.eclipse.hawkbit.repository.FieldValueConverter;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.springframework.beans.SimpleTypeConverter;
import org.springframework.beans.TypeMismatchException;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
/**
* An implementation of the {@link RSQLVisitor} to visit the parsed tokens and
* build JPA where clauses.
*
* @param <A> the enum for providing the field name of the entity field to filter on.
* @param <T> the entity type referenced by the root
* @deprecated Old implementation of RSQL Visitor. Deprecated in favour of next gen implementation - {@link JpaQueryRsqlVisitorG2}.
* It will be kept for some time in order to keep backward compatibility and to allow for a smooth transition. Also, in case of
* problems with the new implementation, this one can be used as a fallback.
*/
@Deprecated(forRemoval = true, since = "0.6.0")
@Slf4j
public class JpaQueryRsqlVisitor<A extends Enum<A> & RsqlQueryField, T> extends AbstractRSQLVisitor<A>
implements RSQLVisitor<List<Predicate>, String> {
public static final Character LIKE_WILDCARD = '*';
private static final char ESCAPE_CHAR = '\\';
private static final List<String> NO_JOINS_OPERATOR = List.of("!=", "=out=");
private static final String ESCAPE_CHAR_WITH_ASTERISK = ESCAPE_CHAR + "*";
private final Map<Integer, Set<Join<Object, Object>>> joinsInLevel = new HashMap<>(3);
private final CriteriaBuilder cb;
private final CriteriaQuery<?> query;
private final Database database;
private final boolean ensureIgnoreCase;
private final Root<T> root;
private final VirtualPropertyReplacer virtualPropertyReplacer;
private final SimpleTypeConverter simpleTypeConverter;
private int level;
private boolean isOrLevel;
private boolean joinsNeeded;
public JpaQueryRsqlVisitor(
final Root<T> root, final CriteriaBuilder cb, final Class<A> enumType,
final VirtualPropertyReplacer virtualPropertyReplacer, final Database database,
final CriteriaQuery<?> query, final boolean ensureIgnoreCase) {
super(enumType);
this.root = root;
this.cb = cb;
this.query = query;
this.virtualPropertyReplacer = virtualPropertyReplacer;
this.simpleTypeConverter = new SimpleTypeConverter();
this.database = database;
this.ensureIgnoreCase = ensureIgnoreCase;
}
@Override
public List<Predicate> visit(final AndNode node, final String param) {
beginLevel(false);
final List<Predicate> childs = acceptChildren(node);
endLevel();
if (!childs.isEmpty()) {
return toSingleList(cb.and(childs.toArray(new Predicate[0])));
}
return toSingleList(cb.conjunction());
}
@Override
public List<Predicate> visit(final OrNode node, final String param) {
beginLevel(true);
final List<Predicate> childs = acceptChildren(node);
endLevel();
if (!childs.isEmpty()) {
return toSingleList(cb.or(childs.toArray(new Predicate[0])));
}
return toSingleList(cb.conjunction());
}
@Override
// Exception squid:S2095 - see
// https://jira.sonarsource.com/browse/SONARJAVA-1478
@SuppressWarnings({ "squid:S2095" })
public List<Predicate> visit(final ComparisonNode node, final String param) {
final QueryPath queryPath = getQueryPath(node);
final List<String> values = node.getArguments();
final List<Object> transformedValues = new ArrayList<>();
final Path<Object> fieldPath = getFieldPath(queryPath.getEnumValue(), queryPath);
for (final String value : values) {
transformedValues.add(convertValueIfNecessary(node, queryPath.getEnumValue(), value, fieldPath));
}
this.joinsNeeded = this.joinsNeeded || areJoinsNeeded(node);
return mapToPredicate(node, fieldPath, node.getArguments(), transformedValues, queryPath);
}
private static List<Predicate> toSingleList(final Predicate predicate) {
return Collections.singletonList(predicate);
}
private static Optional<Path<?>> getFieldPath(final Root<?> root, final String[] split, final boolean isMapKeyField,
final BiFunction<Path<?>, String, Path<?>> joinFieldPathProvider) {
Path<?> fieldPath = null;
for (int i = 0; i < split.length; i++) {
if (!(isMapKeyField && i == (split.length - 1))) {
final String fieldNameSplit = split[i];
fieldPath = (fieldPath != null) ? fieldPath.get(fieldNameSplit) : root.get(fieldNameSplit);
fieldPath = joinFieldPathProvider.apply(fieldPath, fieldNameSplit);
}
}
return Optional.ofNullable(fieldPath);
}
private static boolean areJoinsNeeded(final ComparisonNode node) {
return !NO_JOINS_OPERATOR.contains(node.getOperator().getSymbol());
}
// Exception squid:S2095 - see
// https://jira.sonarsource.com/browse/SONARJAVA-1478
@SuppressWarnings({ "rawtypes", "unchecked", "squid:S2095" })
private static Object transformEnumValue(final ComparisonNode node, final String value, final Class<?> javaType) {
final Class<? extends Enum> tmpEnumType = (Class<? extends Enum>) javaType;
try {
return Enum.valueOf(tmpEnumType, value.toUpperCase());
} catch (final IllegalArgumentException e) {
// we could not transform the given string value into the enum
// type, so ignore it and return null and do not filter
log.info("given value {} cannot be transformed into the correct enum type {}", value.toUpperCase(),
javaType);
log.debug("value cannot be transformed to an enum", e);
throw new RSQLParameterUnsupportedFieldException("field {" + node.getSelector()
+ "} must be one of the following values {" + Arrays.stream(tmpEnumType.getEnumConstants())
.map(v -> v.name().toLowerCase()).toList()
+ "}", e);
}
}
private static boolean isSimpleField(final String[] split, final boolean isMapKeyField) {
return split.length == 1 || (split.length == 2 && isMapKeyField);
}
private static Path<?> getInnerFieldPath(final Root<?> subqueryRoot, final String[] split,
final boolean isMapKeyField) {
return getFieldPath(subqueryRoot, split, isMapKeyField,
(fieldPath, fieldNameSplit) -> getInnerJoinFieldPath(subqueryRoot, fieldPath, fieldNameSplit))
.orElseThrow(() -> new RSQLParameterUnsupportedFieldException("RSQL field path cannot be empty",
null));
}
private static Path<?> getInnerJoinFieldPath(final Root<?> subqueryRoot, final Path<?> fieldPath,
final String fieldNameSplit) {
if (fieldPath instanceof Join) {
return subqueryRoot.join(fieldNameSplit, JoinType.INNER);
}
return fieldPath;
}
private static boolean isPattern(final String transformedValue) {
if (transformedValue.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
return transformedValue.replace(ESCAPE_CHAR_WITH_ASTERISK, "$").indexOf(LIKE_WILDCARD) != -1;
} else {
return transformedValue.indexOf(LIKE_WILDCARD) != -1;
}
}
@SuppressWarnings("unchecked")
private static <Y> Path<Y> pathOfString(final Path<?> path) {
return (Path<Y>) path;
}
private void beginLevel(final boolean isOr) {
level++;
isOrLevel = isOr;
joinsInLevel.put(level, new HashSet<>(2));
}
private void endLevel() {
joinsInLevel.remove(level);
level--;
isOrLevel = false;
}
private Set<Join<Object, Object>> getCurrentJoins() {
if (level > 0) {
return joinsInLevel.get(level);
}
return Collections.emptySet();
}
private Optional<Join<Object, Object>> findCurrentJoinOfType(final Class<?> type) {
return getCurrentJoins().stream().filter(j -> type.equals(j.getJavaType())).findAny();
}
private void addCurrentJoin(final Join<Object, Object> join) {
if (level > 0) {
getCurrentJoins().add(join);
}
}
/**
* Resolves the Path for a field in the persistence layer and joins the
* required models. This operation is part of a tree traversal through an
* RSQL expression. It creates for every field that is not part of the root
* model a join to the foreign model. This behavior is optimized when
* several joins happen directly under an OR node in the traversed tree. The
* same foreign model is only joined once.
*
* Example: tags.name==M;(tags.name==A,tags.name==B,tags.name==C) This
* example joins the tags model only twice, because for the OR node in
* brackets only one join is used.
*
* @param enumField field from a FieldNameProvider to resolve on the persistence
* layer
* @param queryPath RSQL field
* @return the Path for a field
*/
@SuppressWarnings("unchecked")
private Path<Object> getFieldPath(final A enumField, final QueryPath queryPath) {
return (Path<Object>) getFieldPath(root, queryPath.getJpaPath(), enumField.isMap(),
this::getJoinFieldPath).orElseThrow(
() -> new RSQLParameterUnsupportedFieldException("RSQL field path cannot be empty", null));
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private Path<?> getJoinFieldPath(final Path<?> fieldPath, final String fieldNameSplit) {
if (fieldPath instanceof PluralJoin join) {
final From joinParent = join.getParent();
final Optional<Join<Object, Object>> currentJoinOfType = findCurrentJoinOfType(join.getJavaType());
if (currentJoinOfType.isPresent() && isOrLevel) {
// remove the additional join and use the existing one
joinParent.getJoins().remove(join);
return currentJoinOfType.get();
} else {
final Join newJoin = joinParent.join(fieldNameSplit, JoinType.LEFT);
addCurrentJoin(newJoin);
return newJoin;
}
}
return fieldPath;
}
private Object convertValueIfNecessary(final ComparisonNode node, final A fieldName, final String value,
final Path<Object> fieldPath) {
// in case the value of an RSQL query e.g. type==application is an
// enum we need to handle it separately because JPA needs the
// correct java-type to build an expression. So String and numeric
// values JPA can do it by it's own but not for classes like enums.
// So we need to transform the given value string into the enum
// class.
final Class<?> javaType = fieldPath.getJavaType();
if (javaType != null && javaType.isEnum()) {
return transformEnumValue(node, value, javaType);
}
if (fieldName instanceof FieldValueConverter) {
return convertFieldConverterValue(fieldName, value);
}
if (Boolean.TYPE.equals(javaType)) {
return convertBooleanValue(node, value, javaType);
}
return value;
}
private Object convertBooleanValue(final ComparisonNode node, final String value, final Class<?> javaType) {
try {
return simpleTypeConverter.convertIfNecessary(value, javaType);
} catch (final TypeMismatchException e) {
throw new RSQLParameterUnsupportedFieldException(
"The value of the given search parameter field {" + node.getSelector()
+ "} is not well formed. Only a boolean (true or false) value will be expected",
e);
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private Object convertFieldConverterValue(final A fieldName, final String value) {
try {
return ((FieldValueConverter) fieldName).convertValue(fieldName, value);
} catch (final Exception e) {
throw new RSQLParameterSyntaxException(e.getMessage(), null);
}
}
private List<Predicate> mapToPredicate(final ComparisonNode node, final Path<Object> fieldPath,
final List<String> values, final List<Object> transformedValues, final QueryPath queryPath) {
String value = values.get(0);
// if lookup is available, replace macros ...
if (virtualPropertyReplacer != null) {
value = virtualPropertyReplacer.replace(value);
}
final Predicate mapPredicate = mapToMapPredicate(fieldPath, queryPath);
final Predicate valuePredicate = addOperatorPredicate(node, fieldPath, transformedValues, value, queryPath);
return toSingleList(mapPredicate != null ? cb.and(mapPredicate, valuePredicate) : valuePredicate);
}
private Predicate addOperatorPredicate(final ComparisonNode node, final Path<Object> fieldPath,
final List<Object> transformedValues, final String value, final QueryPath queryPath) {
// only 'equal' and 'notEqual' can handle transformed value like
// enums. The JPA API cannot handle object types for greaterThan etc
// methods.
final Object transformedValue = transformedValues.get(0);
final String operator = node.getOperator().getSymbol();
switch (operator) {
case "==":
return getEqualToPredicate(transformedValue, fieldPath);
case "!=":
return getNotEqualToPredicate(transformedValue, fieldPath, queryPath);
case "=gt=":
return cb.greaterThan(pathOfString(fieldPath), value);
case "=ge=":
return cb.greaterThanOrEqualTo(pathOfString(fieldPath), value);
case "=lt=":
return cb.lessThan(pathOfString(fieldPath), value);
case "=le=":
return cb.lessThanOrEqualTo(pathOfString(fieldPath), value);
case "=in=":
return in(pathOfString(fieldPath), transformedValues);
case "=out=":
return getOutPredicate(transformedValues, queryPath, fieldPath);
default:
throw new RSQLParameterSyntaxException(
"operator symbol {" + operator + "} is either not supported or not implemented");
}
}
private Predicate getOutPredicate(
final List<Object> transformedValues,
final QueryPath queryPath, final Path<Object> fieldPath) {
final String[] fieldNames = queryPath.getJpaPath();
if (isSimpleField(fieldNames, queryPath.getEnumValue().isMap())) {
final Path<String> pathOfString = pathOfString(fieldPath);
return cb.or(cb.isNull(pathOfString), cb.not(in(pathOfString, transformedValues)));
}
clearOuterJoinsIfNotNeeded();
return toNotExistsSubQueryPredicate(fieldNames, queryPath.getEnumValue(),
expressionToCompare -> in(expressionToCompare, transformedValues));
}
@SuppressWarnings("unchecked")
private Predicate mapToMapPredicate(final Path<Object> fieldPath, final QueryPath queryPath) {
if (!queryPath.getEnumValue().isMap()) {
return null;
}
final String[] graph = queryPath.getJpaPath();
final String keyValue = graph[graph.length - 1];
// Currently we support only string key. So below cast is safe.
return equal((Expression<String>) (((MapJoin<?, ?, ?>) fieldPath).key()), keyValue);
}
private Predicate getEqualToPredicate(final Object transformedValue, final Path<Object> fieldPath) {
if (transformedValue == null) {
return cb.isNull(pathOfString(fieldPath));
}
if ((transformedValue instanceof String transformedValueStr) && !NumberUtils.isCreatable(transformedValueStr)) {
if (ObjectUtils.isEmpty(transformedValue)) {
return cb.or(cb.isNull(pathOfString(fieldPath)), cb.equal(pathOfString(fieldPath), ""));
}
if (isPattern(transformedValueStr)) { // a pattern, use like
return like(pathOfString(fieldPath), toSQL(transformedValueStr));
} else {
return equal(pathOfString(fieldPath), transformedValueStr);
}
}
return cb.equal(fieldPath, transformedValue);
}
private Predicate getNotEqualToPredicate(final Object transformedValue, final Path<Object> fieldPath,
final QueryPath queryPath) {
if (transformedValue == null) {
return cb.isNotNull(pathOfString(fieldPath));
}
if ((transformedValue instanceof String transformedValueStr) && !NumberUtils.isCreatable(transformedValueStr)) {
if (ObjectUtils.isEmpty(transformedValue)) {
return cb.and(cb.isNotNull(pathOfString(fieldPath)), cb.notEqual(pathOfString(fieldPath), ""));
}
final String[] fieldNames = queryPath.getJpaPath();
if (isSimpleField(fieldNames, queryPath.getEnumValue().isMap())) {
if (isPattern(transformedValueStr)) { // a pattern, use like
return cb.or(cb.isNull(pathOfString(fieldPath)), notLike(pathOfString(fieldPath), toSQL(transformedValueStr)));
} else {
return toNullOrNotEqualPredicate(fieldPath, transformedValueStr);
}
}
clearOuterJoinsIfNotNeeded();
if (isPattern(transformedValueStr)) { // a pattern, use like
return toNotExistsSubQueryPredicate(fieldNames, queryPath.getEnumValue(),
expressionToCompare -> like(expressionToCompare, toSQL(transformedValueStr)));
} else {
return toNotExistsSubQueryPredicate(fieldNames, queryPath.getEnumValue(),
expressionToCompare -> equal(expressionToCompare, transformedValueStr));
}
}
return toNullOrNotEqualPredicate(fieldPath, transformedValue);
}
private void clearOuterJoinsIfNotNeeded() {
if (!joinsNeeded) {
root.getJoins().clear();
}
}
private Predicate toNullOrNotEqualPredicate(final Path<Object> fieldPath, final Object transformedValue) {
return cb.or(
cb.isNull(pathOfString(fieldPath)),
transformedValue instanceof String transformedValueStr
? notEqual(pathOfString(fieldPath), transformedValueStr)
: cb.notEqual(fieldPath, transformedValue));
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private Predicate toNotExistsSubQueryPredicate(final String[] fieldNames, final A enumField,
final Function<Expression<String>, Predicate> subQueryPredicateProvider) {
final Class<?> javaType = root.getJavaType();
final Subquery<?> subquery = query.subquery(javaType);
final Root subqueryRoot = subquery.from(javaType);
final Predicate equalPredicate = cb.equal(root.get(enumField.identifierFieldName()),
subqueryRoot.get(enumField.identifierFieldName()));
final Path innerFieldPath = getInnerFieldPath(subqueryRoot, fieldNames, enumField.isMap());
final Expression<String> expressionToCompare = getExpressionToCompare(innerFieldPath, enumField);
final Predicate subQueryPredicate = subQueryPredicateProvider.apply(expressionToCompare);
subquery.select(subqueryRoot).where(cb.and(equalPredicate, subQueryPredicate));
return cb.not(cb.exists(subquery));
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private Expression<String> getExpressionToCompare(final Path innerFieldPath, final A enumField) {
if (enumField.isMap()){
// Currently we support only string key. So below cast is safe.
return (Expression<String>) (((MapJoin<?, ?, ?>) pathOfString(innerFieldPath)).value());
} else {
return pathOfString(innerFieldPath);
}
}
private String toSQL(final String transformedValue) {
final String escaped;
if (database == Database.SQL_SERVER) {
escaped = transformedValue.replace("%", "[%]").replace("_", "[_]");
} else {
escaped = transformedValue.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
}
return replaceIfRequired(escaped);
}
private String replaceIfRequired(final String escapedValue) {
final String finalizedValue;
if (escapedValue.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
finalizedValue = escapedValue.replace(ESCAPE_CHAR_WITH_ASTERISK, "$").replace(LIKE_WILDCARD, '%')
.replace("$", ESCAPE_CHAR_WITH_ASTERISK);
} else {
finalizedValue = escapedValue.replace(LIKE_WILDCARD, '%');
}
return finalizedValue;
}
private List<Predicate> acceptChildren(final LogicalNode node) {
final List<Node> children = node.getChildren();
final List<Predicate> childs = new ArrayList<>();
for (final Node node2 : children) {
final List<Predicate> accept = node2.accept(this);
if (!CollectionUtils.isEmpty(accept)) {
childs.addAll(accept);
} else {
log.debug("visit logical node children but could not parse it, ignoring {}", node2);
}
}
return childs;
}
@SuppressWarnings("java:S1221") // java:S1221 - intentionally to match the SQL wording
private Predicate equal(final Expression<String> expressionToCompare, final String sqlValue) {
return cb.equal(caseWise(cb, expressionToCompare), caseWise(sqlValue));
}
private Predicate notEqual(final Expression<String> expressionToCompare, String transformedValueStr) {
return cb.notEqual(caseWise(cb, expressionToCompare), caseWise(transformedValueStr));
}
private Predicate like(final Expression<String> expressionToCompare, final String sqlValue) {
return cb.like(caseWise(cb, expressionToCompare), caseWise(sqlValue), ESCAPE_CHAR);
}
private Predicate notLike(final Expression<String> expressionToCompare, final String sqlValue) {
return cb.notLike(caseWise(cb, expressionToCompare), caseWise(sqlValue), ESCAPE_CHAR);
}
private Predicate in(final Expression<String> expressionToCompare, final List<Object> transformedValues) {
final List<String> inParams = transformedValues.stream().filter(String.class::isInstance)
.map(String.class::cast).map(this::caseWise).toList();
return inParams.isEmpty() ? expressionToCompare.in(transformedValues) : caseWise(cb, expressionToCompare).in(inParams);
}
private Expression<String> caseWise(final CriteriaBuilder cb, final Expression<String> expression) {
if (expression instanceof Path<String> path && path.getJavaType() != String.class) {
return expression; // non string values doesn't support uppercase
} else {
return ensureIgnoreCase ? cb.upper(expression) : expression;
}
}
private String caseWise(final String str) {
return ensureIgnoreCase ? str.toUpperCase() : str;
}
}

View File

@@ -0,0 +1,503 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.legacy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.JoinType;
import jakarta.persistence.criteria.MapJoin;
import jakarta.persistence.criteria.Path;
import jakarta.persistence.criteria.PluralJoin;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery;
import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.SingularAttribute;
import jakarta.persistence.metamodel.Type;
import cz.jirutka.rsql.parser.ast.AndNode;
import cz.jirutka.rsql.parser.ast.ComparisonNode;
import cz.jirutka.rsql.parser.ast.ComparisonOperator;
import cz.jirutka.rsql.parser.ast.LogicalNode;
import cz.jirutka.rsql.parser.ast.Node;
import cz.jirutka.rsql.parser.ast.OrNode;
import cz.jirutka.rsql.parser.ast.RSQLOperators;
import cz.jirutka.rsql.parser.ast.RSQLVisitor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.math.NumberUtils;
import org.eclipse.hawkbit.repository.FieldValueConverter;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException;
import org.eclipse.hawkbit.repository.jpa.ql.SpecificationBuilder;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.util.CollectionUtils;
import org.springframework.util.ObjectUtils;
/**
* An implementation of the {@link RSQLVisitor} to visit the parsed tokens and build JPA where clauses.
*
* @param <A> the enum for providing the field name of the entity field to filter on.
* @param <T> the entity type referenced by the root
* @deprecated Old implementation of RSQL Visitor (G2). Deprecated in favour of next gen implementation -
* {@link SpecificationBuilder}.
* It will be kept for some time in order to keep backward compatibility and to allow for a smooth transition. Also, in case of
* problems with the new implementation, this one can be used as a fallback.
*/
@Deprecated(forRemoval = true, since = "0.9.0")
@Slf4j
public class JpaQueryRsqlVisitorG2<A extends Enum<A> & RsqlQueryField, T>
extends AbstractRSQLVisitor<A> implements RSQLVisitor<List<Predicate>, String> {
public static final Character LIKE_WILDCARD = '*';
private static final char ESCAPE_CHAR = '\\';
private static final String ESCAPE_CHAR_WITH_ASTERISK = ESCAPE_CHAR + "*";
private final Root<T> root;
private final CriteriaQuery<?> query;
private final CriteriaBuilder cb;
private final Database database;
private final VirtualPropertyReplacer virtualPropertyReplacer;
private final boolean ensureIgnoreCase;
private final Map<String, Path<?>> attributeToPath = new HashMap<>();
private boolean inOr;
public JpaQueryRsqlVisitorG2(
final Class<A> enumType,
final Root<T> root, final CriteriaQuery<?> query, final CriteriaBuilder cb,
final Database database, final VirtualPropertyReplacer virtualPropertyReplacer, final boolean ensureIgnoreCase) {
super(enumType);
this.root = root;
this.cb = cb;
this.query = query;
this.virtualPropertyReplacer = virtualPropertyReplacer;
this.database = database;
this.ensureIgnoreCase = ensureIgnoreCase;
}
@Override
public List<Predicate> visit(final AndNode node, final String param) {
final List<Predicate> children = acceptChildren(node);
return Collections.singletonList(children.isEmpty() ? cb.conjunction() : cb.and(children.toArray(new Predicate[0])));
}
@Override
public List<Predicate> visit(final OrNode node, final String param) {
inOr = true;
try {
final List<Predicate> children = acceptChildren(node);
return Collections.singletonList(children.isEmpty() ? cb.conjunction() : cb.or(children.toArray(new Predicate[0])));
} finally {
inOr = false;
attributeToPath.clear();
}
}
@Override
public List<Predicate> visit(final ComparisonNode node, final String param) {
final QueryPath queryPath = getQueryPath(node);
final Path<?> fieldPath = getFieldPath(root, queryPath);
return Collections.singletonList(toPredicate(node, queryPath, fieldPath, getValues(node, queryPath, fieldPath)));
}
@SuppressWarnings("java:S3776") // java:S3776 - easier to read at one place
private Predicate toPredicate(final ComparisonNode node, final QueryPath queryPath, final Path<?> fieldPath, final List<Object> values) {
final Predicate mapEntryKeyPredicate;
if (queryPath.getEnumValue().isMap()) {
if (node.getOperator() == IS) {
// special handling of "not-exists"
if (values.size() != 1) {
throw new RSQLParameterSyntaxException("The operator '" + IS + "' can only be used with one value");
}
if (values.get(0) == null) {
// IS operator for maps and null value is treated as doesn't exist correspondingly
((PluralJoin<?, ?, ?>) fieldPath).on(toMapEntryKeyPredicate(queryPath, fieldPath));
return cb.isNull(fieldPath);
}
} else if (node.getOperator() == NOT) {
if (values.size() != 1) {
throw new RSQLParameterSyntaxException("The operator '" + NOT + "' can only be used with one value");
}
// NOT operator for maps and null value is treated as does exist correspondingly
((PluralJoin<?, ?, ?>) fieldPath).on(toMapEntryKeyPredicate(queryPath, fieldPath));
if (values.get(0) == null) {
// special handling of "exists"
return cb.isNotNull(fieldPath);
} else {
// special handling or "not equal" or null (same as != but with possible optimized join - no subquery)
return toNotEqualToPredicate(queryPath, fieldPath, values.get(0));
}
}
mapEntryKeyPredicate = toMapEntryKeyPredicate(queryPath, fieldPath);
} else {
mapEntryKeyPredicate = null;
}
final Predicate valuePredicate = toOperatorAndValuePredicate(node, queryPath, fieldPath, values);
return mapEntryKeyPredicate == null ? valuePredicate : cb.and(mapEntryKeyPredicate, valuePredicate);
}
@SuppressWarnings("unchecked")
private Predicate toMapEntryKeyPredicate(final QueryPath queryPath, final Path<?> fieldPath) {
final String[] graph = queryPath.getJpaPath();
return equal((Path<String>) ((MapJoin<?, ?, ?>) fieldPath).key(), graph[graph.length - 1]);
}
private Predicate toOperatorAndValuePredicate(
final ComparisonNode node, final QueryPath queryPath, final Path<?> fieldPath, final List<Object> values) {
// only 'equal' and 'notEqual' can handle transformed value like enums.
// The JPA API cannot handle object types for greaterThan etc. methods. For them, it shall be a string.
final Object value = values.get(0);
final String operator = node.getOperator().getSymbol();
return switch (operator) {
case "==", "=is=", "=eq=" -> toEqualToPredicate(fieldPath, value);
case "!=", "=not=", "=ne=" -> toNotEqualToPredicate(queryPath, fieldPath, value);
case "=gt=" -> cb.greaterThan(pathOfString(fieldPath), String.valueOf(value)); // JPA handles numbers
case "=ge=" -> cb.greaterThanOrEqualTo(pathOfString(fieldPath), String.valueOf(value));
case "=lt=" -> cb.lessThan(pathOfString(fieldPath), String.valueOf(value));
case "=le=" -> cb.lessThanOrEqualTo(pathOfString(fieldPath), String.valueOf(value));
case "=in=" -> in(pathOfString(fieldPath), values);
case "=out=" -> toOutPredicate(queryPath, fieldPath, values);
default -> throw new RSQLParameterSyntaxException("Operator symbol {" + operator + "} is either not supported or not implemented");
};
}
private Predicate toEqualToPredicate(final Path<?> fieldPath, final Object value) {
if (value == null) {
return cb.isNull(fieldPath);
}
if ((value instanceof String valueStr) && !NumberUtils.isCreatable(valueStr)) {
if (ObjectUtils.isEmpty(value)) {
return cb.or(cb.isNull(fieldPath), cb.equal(pathOfString(fieldPath), ""));
}
final Path<String> stringExpression = pathOfString(fieldPath);
if (isPattern(valueStr)) { // a pattern, use like
return like(stringExpression, toSQL(valueStr));
} else {
return equal(stringExpression, valueStr);
}
}
return cb.equal(fieldPath, value);
}
// if value is null -> not null
// if value is not null -> null or not equal value
private Predicate toNotEqualToPredicate(final QueryPath queryPath, final Path<?> fieldPath, final Object value) {
if (value == null) {
return cb.isNotNull(fieldPath);
}
if (value instanceof String valueStr && !NumberUtils.isCreatable(valueStr)) {
if (ObjectUtils.isEmpty(value)) {
return cb.and(cb.isNotNull(fieldPath), cb.notEqual(pathOfString(fieldPath), ""));
}
if (isSimpleField(queryPath)) {
if (isPattern(valueStr)) { // a pattern, use like
return cb.or(cb.isNull(fieldPath), notLike(pathOfString(fieldPath), toSQL(valueStr)));
} else {
return toNullOrNotEqualPredicate(fieldPath, valueStr);
}
}
return toNotExistsSubQueryPredicate(
queryPath, fieldPath, expressionToCompare ->
isPattern(valueStr)
? like(expressionToCompare, toSQL(valueStr)) // a pattern, use like
: equal(expressionToCompare, valueStr));
}
return toNullOrNotEqualPredicate(fieldPath, value);
}
private Predicate toOutPredicate(final QueryPath queryPath, final Path<?> fieldPath, final List<Object> values) {
if (isSimpleField(queryPath)) {
return cb.or(cb.isNull(fieldPath), cb.not(in(pathOfString(fieldPath), values)));
}
return toNotExistsSubQueryPredicate(queryPath, fieldPath, expressionToCompare -> in(expressionToCompare, values));
}
private Path<?> getFieldPath(final Root<?> root, final QueryPath queryPath) {
final String[] split = queryPath.getJpaPath();
Path<?> fieldPath = null;
for (int i = 0, end = queryPath.getEnumValue().isMap() ? split.length - 1 : split.length; i < end; i++) {
final String fieldNameSplit = split[i];
fieldPath = fieldPath == null ? getPath(root, fieldNameSplit) : fieldPath.get(fieldNameSplit);
}
if (fieldPath == null) {
throw new RSQLParameterUnsupportedFieldException("RSQL field path must not be null", null);
}
return fieldPath;
}
@SuppressWarnings("java:S3776") // java:S3776 - easier to read at one place
private List<Object> getValues(final ComparisonNode node, final AbstractRSQLVisitor<A>.QueryPath queryPath, final Path<?> fieldPath) {
final List<Object> values = node.getArguments().stream()
// if lookup is available, replace macros ...
.map(value -> virtualPropertyReplacer == null ? value : virtualPropertyReplacer.replace(value))
// converts value to the correct type
.map(value -> convertValueIfNecessary(node, queryPath.getEnumValue(), fieldPath, value))
.toList();
if (values.isEmpty()) {
throw new RSQLParameterSyntaxException("RSQL values must not be empty", null);
} else if (values.size() == 1) {
if (!(values.get(0) instanceof String)) { // enum or boolean or null - doesn's support >, >=, <, <=
final ComparisonOperator operator = node.getOperator();
if (operator == RSQLOperators.GREATER_THAN ||
operator == RSQLOperators.GREATER_THAN_OR_EQUAL ||
operator == RSQLOperators.LESS_THAN ||
operator == RSQLOperators.LESS_THAN_OR_EQUAL) {
final String errorMsg = values.get(0) == null ? "to null value" : "to enum or boolean field";
throw new RSQLParameterSyntaxException(operator.getSymbol() + " operator could not be applied " + errorMsg, null);
}
}
} else {
final ComparisonOperator operator = node.getOperator();
if (operator != RSQLOperators.IN && operator != RSQLOperators.NOT_IN) {
throw new RSQLParameterSyntaxException(operator.getSymbol() + " operator shall have exactly one value", null);
}
}
return values;
}
// if root.get creates a join we call join directly in order to specify LEFT JOIN type, to include rows for missing in particular
// table / criteria (root.get creates INNER JOIN) (see org.eclipse.persistence.internal.jpa.querydef.FromImpl implementation
// for more details) otherwise delegate to root.get
@SuppressWarnings("java:S1066") // java:S1066 - better reading this way
private Path<?> getPath(final Root<?> root, final String fieldNameSplit) {
// see org.eclipse.persistence.internal.jpa.querydef.FromImpl implementation for more details when root.get creates a join
final Attribute<?, ?> attribute = root.getModel().getAttribute(fieldNameSplit);
if (!attribute.isCollection()) {
// it is a SingularAttribute and not join if it is of basic or entity persistence type
final Type.PersistenceType persistenceType = ((SingularAttribute<?, ?>) attribute).getType().getPersistenceType();
if (persistenceType == Type.PersistenceType.BASIC) {
return root.get(fieldNameSplit);
} else if (persistenceType == Type.PersistenceType.ENTITY) {
return root.getJoins().stream()
.filter(join -> join.getAttribute().equals(attribute))
.findFirst()
.orElseGet(() -> root.join(fieldNameSplit, JoinType.LEFT));
}
}
// if a collection - it is a join
if (inOr && root == this.root) { // try to reuse join of the same "or" level and no subquery
return attributeToPath.computeIfAbsent(attribute.getName(), k -> root.join(fieldNameSplit, JoinType.LEFT));
} else {
return root.join(fieldNameSplit, JoinType.LEFT);
}
}
private Predicate toNullOrNotEqualPredicate(final Path<?> fieldPath, final Object value) {
return cb.or(
cb.isNull(fieldPath),
value instanceof String valueStr ? notEqual(pathOfString(fieldPath), valueStr) : cb.notEqual(fieldPath, value));
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private Predicate toNotExistsSubQueryPredicate(
final QueryPath queryPath, final Path<?> fieldPath, final Function<Path<String>, Predicate> subQueryPredicateProvider) {
// if a subquery the field's parent joins are not actually used
if (!inOr) {
// so, if not in or (hence not reused) we remove them. Parent shall be a Join
root.getJoins().remove(fieldPath.getParentPath());
}
final Class<?> javaType = root.getJavaType();
final Subquery<?> subquery = query.subquery(javaType);
final Root subqueryRoot = subquery.from(javaType);
return cb.not(cb.exists(
subquery.select(subqueryRoot)
.where(cb.and(
cb.equal(
root.get(queryPath.getEnumValue().identifierFieldName()),
subqueryRoot.get(queryPath.getEnumValue().identifierFieldName())),
subQueryPredicateProvider.apply(
getExpressionToCompare(queryPath.getEnumValue(), getFieldPath(subqueryRoot, queryPath)))))));
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private Path<String> getExpressionToCompare(final A enumField, final Path fieldPath) {
if (enumField.isMap()) {
// Currently we support only string key. So below cast is safe.
return (Path<String>) (((MapJoin<?, ?, ?>) fieldPath).value());
} else {
return pathOfString(fieldPath);
}
}
// result is String, enum value, boolean or null
@SuppressWarnings({ "rawtypes", "unchecked" })
private Object convertValueIfNecessary(final ComparisonNode node, final A enumValue, final Path<?> fieldPath, final String value) {
// in case the value of an RSQL query is an enum we need to transform the given value to the correspondent java type object
final Class<?> javaType = fieldPath.getJavaType();
if (javaType != null && javaType.isEnum()) {
return toEnumValue(node, javaType, value);
}
if (enumValue instanceof FieldValueConverter fieldValueConverter) {
try {
return fieldValueConverter.convertValue(enumValue, value);
} catch (final Exception e) {
throw new RSQLParameterUnsupportedFieldException(e.getMessage(), null);
}
}
if (boolean.class.equals(javaType) || Boolean.class.equals(javaType)) {
if ("true".equals(value) || "false".equals(value)) {
return Boolean.valueOf(value);
} else {
throw new RSQLParameterSyntaxException(
"The value of the given search parameter field {" + node.getSelector() + "} is not well formed. " +
"Only a boolean (true or false) value will be expected");
}
}
if ("null".equals(value)) {
final ComparisonOperator operator = node.getOperator();
if (operator == IS || operator == NOT) {
return null;
}
}
return value;
}
private boolean isSimpleField(final QueryPath queryPath) {
return queryPath.getJpaPath().length == 1 || (queryPath.getJpaPath().length == 2 && queryPath.getEnumValue().isMap());
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private static Object toEnumValue(final ComparisonNode node, final Class<?> javaType, final String value) {
final Class<? extends Enum> tmpEnumType = (Class<? extends Enum>) javaType;
try {
return Enum.valueOf(tmpEnumType, value.toUpperCase());
} catch (final IllegalArgumentException e) {
// we could not transform the given string value into the enum type, so ignore it and return null and do not filter
log.info("given value {} cannot be transformed into the correct enum type {}", value.toUpperCase(), javaType);
log.debug("value cannot be transformed to an enum", e);
throw new RSQLParameterUnsupportedFieldException(
"field '" + node.getSelector() + "' must be one of the following values " +
"{" + Arrays.stream(tmpEnumType.getEnumConstants()).map(Enum::name).map(String::toLowerCase).toList() + "}",
e);
}
}
@SuppressWarnings("unchecked")
private static Path<String> pathOfString(final Path<?> path) {
return (Path<String>) path;
}
private static boolean isPattern(final String value) {
if (value.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
return value.replace(ESCAPE_CHAR_WITH_ASTERISK, "$").indexOf(LIKE_WILDCARD) != -1;
} else {
return value.indexOf(LIKE_WILDCARD) != -1;
}
}
private String toSQL(final String value) {
final String escaped;
if (database == Database.SQL_SERVER) {
escaped = value.replace("%", "[%]").replace("_", "[_]");
} else {
escaped = value.replace("%", ESCAPE_CHAR + "%").replace("_", ESCAPE_CHAR + "_");
}
return replaceIfRequired(escaped);
}
private static String replaceIfRequired(final String escapedValue) {
final String finalizedValue;
if (escapedValue.contains(ESCAPE_CHAR_WITH_ASTERISK)) {
finalizedValue = escapedValue.replace(ESCAPE_CHAR_WITH_ASTERISK, "$")
.replace(LIKE_WILDCARD, '%')
.replace("$", ESCAPE_CHAR_WITH_ASTERISK);
} else {
finalizedValue = escapedValue.replace(LIKE_WILDCARD, '%');
}
return finalizedValue;
}
private List<Predicate> acceptChildren(final LogicalNode node) {
final List<Predicate> children = new ArrayList<>();
for (final Node child : node.getChildren()) {
final List<Predicate> accept = child.accept(this);
if (!CollectionUtils.isEmpty(accept)) {
children.addAll(accept);
} else {
log.debug("visit logical node children but could not parse it, ignoring {}", child);
}
}
return children;
}
@SuppressWarnings("java:S1221") // java:S1221 - intentionally to match the SQL wording
private Predicate equal(final Path<String> expressionToCompare, final String sqlValue) {
if (caseWise(expressionToCompare)) {
return cb.equal(cb.upper(expressionToCompare), sqlValue.toUpperCase());
} else {
return cb.equal(expressionToCompare, sqlValue);
}
}
private Predicate notEqual(final Path<String> expressionToCompare, String valueStr) {
if (caseWise(expressionToCompare)) {
return cb.notEqual(cb.upper(expressionToCompare), valueStr.toUpperCase());
} else {
return cb.notEqual(expressionToCompare, valueStr);
}
}
private Predicate like(final Path<String> expressionToCompare, final String sqlValue) {
if (caseWise(expressionToCompare)) {
return cb.like(cb.upper(expressionToCompare), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.like(expressionToCompare, sqlValue, ESCAPE_CHAR);
}
}
private Predicate notLike(final Path<String> expressionToCompare, final String sqlValue) {
if (caseWise(expressionToCompare)) {
return cb.notLike(cb.upper(expressionToCompare), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.notLike(expressionToCompare, sqlValue, ESCAPE_CHAR);
}
}
private Predicate in(final Path<String> expressionToCompare, final List<Object> values) {
if (ensureIgnoreCase && expressionToCompare.getJavaType() == String.class) {
final List<String> inParams = values.stream()
.filter(String.class::isInstance)
.map(String.class::cast).map(String::toUpperCase).toList();
return inParams.isEmpty() ? expressionToCompare.in(values) : cb.upper(expressionToCompare).in(inParams);
} else {
return expressionToCompare.in(values);
}
}
private boolean caseWise(final Path<?> fieldPath) {
return ensureIgnoreCase && fieldPath.getJavaType() == String.class;
}
}

View File

@@ -0,0 +1,83 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.legacy;
import static org.eclipse.hawkbit.repository.jpa.rsql.legacy.AbstractRSQLVisitor.OPERATORS;
import static org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1;
import java.util.List;
import jakarta.persistence.criteria.Predicate;
import cz.jirutka.rsql.parser.RSQLParser;
import cz.jirutka.rsql.parser.RSQLParserException;
import cz.jirutka.rsql.parser.ast.Node;
import cz.jirutka.rsql.parser.ast.RSQLVisitor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException;
import org.eclipse.hawkbit.repository.jpa.ql.SpecificationBuilder;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder;
import org.eclipse.hawkbit.repository.rsql.VirtualPropertyReplacer;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.util.CollectionUtils;
/**
* @deprecated Old implementation of RSQL Visitor (G2). Deprecated in favour of next gen implementation - {@link SpecificationBuilder}.
*/
@Deprecated(forRemoval = true, since = "0.9.0")
@Slf4j
public class SpecificationBuilderLegacy<A extends Enum<A> & RsqlQueryField, T> {
private final Class<A> rsqlQueryFieldType;
private final VirtualPropertyReplacer virtualPropertyReplacer;
private final Database database;
public SpecificationBuilderLegacy(
final Class<A> rsqlQueryFieldType, final VirtualPropertyReplacer virtualPropertyReplacer, final Database database) {
this.rsqlQueryFieldType = rsqlQueryFieldType;
this.virtualPropertyReplacer = virtualPropertyReplacer;
this.database = database;
}
public Specification<T> specification(final String rsql) {
return (root, query, cb) -> {
final RsqlConfigHolder rsqlConfigHolder = RsqlConfigHolder.getInstance();
final Node rootNode = parseRsql(rsql, rsqlConfigHolder);
query.distinct(true);
final boolean ensureIgnoreCase = !rsqlConfigHolder.isCaseInsensitiveDB() && rsqlConfigHolder.isIgnoreCase();
final RSQLVisitor<List<Predicate>, String> jpqQueryRSQLVisitor =
rsqlConfigHolder.getRsqlToSpecBuilder() == LEGACY_G1
? new JpaQueryRsqlVisitor<>(root, cb, rsqlQueryFieldType, virtualPropertyReplacer, database, query, ensureIgnoreCase)
: new JpaQueryRsqlVisitorG2<>(rsqlQueryFieldType, root, query, cb, database, virtualPropertyReplacer, ensureIgnoreCase);
final List<Predicate> accept = rootNode.accept(jpqQueryRSQLVisitor);
if (CollectionUtils.isEmpty(accept)) {
return cb.conjunction();
} else {
return cb.and(accept.toArray(new Predicate[0]));
}
};
}
private static Node parseRsql(final String rsql, final RsqlConfigHolder rsqlConfigHolder) {
log.debug("Parsing rsql string {}", rsql);
try {
return new RSQLParser(OPERATORS).parse(rsqlConfigHolder.isCaseInsensitiveDB() || rsqlConfigHolder.isIgnoreCase() ? rsql.toLowerCase() : rsql);
} catch (final IllegalArgumentException e) {
throw new RSQLParameterSyntaxException("RSQL filter must not be null", e);
} catch (final RSQLParserException e) {
throw new RSQLParameterSyntaxException(e);
}
}
}

View File

@@ -0,0 +1,62 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import java.util.Map;
import java.util.Set;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.MapKeyColumn;
import lombok.Data;
import lombok.experimental.Accessors;
@Entity
@Data
@Accessors(chain = true)
class Root {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// singular attributes
// basic
private String strValue;
private int intValue;
// entity
@ManyToOne
private Sub subEntity;
// set searchable by key
@ManyToMany(targetEntity = Sub.class)
@JoinTable(
name = "subs",
joinColumns = { @JoinColumn(name = "root") },
inverseJoinColumns = { @JoinColumn(name = "subs") })
private Set<Sub> subSet;
// standard map
@ElementCollection
@CollectionTable(
name = "map",
joinColumns = { @JoinColumn(name = "root") })
@MapKeyColumn(name = "map_key", length = 128)
@Column(name = "map_value", length = 128)
private Map<String, String> subMap;
}

View File

@@ -0,0 +1,17 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface RootRepository extends CrudRepository<Root, Long>, JpaSpecificationExecutor<Root> {}

View File

@@ -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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import static org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1;
import static org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G2;
import java.util.Arrays;
import java.util.List;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.RsqlQueryField;
import org.eclipse.hawkbit.repository.jpa.rsql.legacy.SpecificationBuilderLegacy;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder;
import org.junit.jupiter.api.Test;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.orm.jpa.vendor.Database;
@SuppressWarnings("java:S2699") // java:S2699 - assertions are un the super methods that are called
@Slf4j
class SpecificationBuilderLegacyTest extends SpecificationBuilderTest {
private final SpecificationBuilderLegacy<RootField, Root> builder = new SpecificationBuilderLegacy<>(RootField.class, null, Database.H2);
private static void runWithRsqlToSpecBuilder(final Runnable runnable, final RsqlToSpecBuilder rsqlToSpecBuilder) {
final RsqlToSpecBuilder defaultBuilder = RsqlConfigHolder.getInstance().getRsqlToSpecBuilder();
RsqlConfigHolder.getInstance().setRsqlToSpecBuilder(rsqlToSpecBuilder);
try {
runnable.run();
} finally {
RsqlConfigHolder.getInstance().setRsqlToSpecBuilder(defaultBuilder);
}
}
@Test
void singularStringAttributeG1() {
runWithRsqlToSpecBuilder(super::singularStringAttribute, LEGACY_G1);
}
@Override
@Test
void singularStringAttribute() {
runWithRsqlToSpecBuilder(super::singularStringAttribute, LEGACY_G2);
}
@Test
void singularIntAttributeG1() {
runWithRsqlToSpecBuilder(super::singularIntAttribute, LEGACY_G1);
}
@Override
@Test
void singularIntAttribute() {
runWithRsqlToSpecBuilder(super::singularIntAttribute, LEGACY_G2);
}
@Test
void singularEntityAttributeG1() {
runWithRsqlToSpecBuilder(super::singularEntityAttribute, LEGACY_G1);
}
@Override
@Test
void singularEntityAttribute() {
runWithRsqlToSpecBuilder(super::singularEntityAttribute, LEGACY_G2);
}
@Test
void pluralSubSetAttributeG1() {
runWithRsqlToSpecBuilder(super::pluralSubSetAttribute, LEGACY_G1);
}
@Override
@Test
void pluralSubSetAttribute() {
runWithRsqlToSpecBuilder(super::pluralSubSetAttribute, LEGACY_G2);
}
// Legacy G1 doesn't support hibernate maps
@Override
@Test
void pluralSubMapAttribute() {
runWithRsqlToSpecBuilder(super::pluralSubMapAttribute, LEGACY_G2);
}
@Test
void singularEntitySubSubAttributeG1() {
runWithRsqlToSpecBuilder(super::singularEntitySubSubAttribute, LEGACY_G1);
}
@Override
@Test
void singularEntitySubSubAttribute() {
runWithRsqlToSpecBuilder(super::singularEntitySubSubAttribute, LEGACY_G2);
}
@Override
protected Specification<Root> getSpecification(final String rsql) {
return builder.specification(rsql);
}
@Getter
private enum RootField implements RsqlQueryField {
INTVALUE("intValue"),
STRVALUE("strValue"),
SUBENTITY("subEntity", "strValue", "intValue", "subSub"),
SUBSET("subSet", "strValue", "intValue"),
SUBMAP("subMap");
private final String jpaEntityFieldName;
private final List<String> subEntityAttributes;
RootField(final String jpaEntityFieldName, final String... subFields) {
this.jpaEntityFieldName = jpaEntityFieldName;
this.subEntityAttributes = Arrays.asList(subFields);
}
@Override
public boolean isMap() {
return this == SUBMAP;
}
}
}

View File

@@ -0,0 +1,500 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import static org.assertj.core.api.Assertions.assertThat;
import static org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder.G3;
import static org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G1;
import static org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder.RsqlToSpecBuilder.LEGACY_G2;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.StreamSupport;
import jakarta.persistence.EntityManager;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.repository.jpa.ql.EntityMatcher;
import org.eclipse.hawkbit.repository.jpa.ql.utils.HawkbitQlToSql;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParser;
import org.eclipse.hawkbit.repository.jpa.ql.SpecificationBuilder;
import org.eclipse.hawkbit.repository.jpa.rsql.RsqlConfigHolder;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.orm.jpa.vendor.Database;
@SuppressWarnings("java:S5961") // complex check because the matter is very complex
@DataJpaTest(properties = {
"spring.jpa.database=H2"
}, excludeAutoConfiguration = { FlywayAutoConfiguration.class })
@EnableAutoConfiguration
@Slf4j
class SpecificationBuilderTest {
@Autowired
private SubSubRepository subSubRepository;
@Autowired
private SubRepository subRepository;
@Autowired
private RootRepository rootRepository;
@Autowired
private EntityManager entityManager;
private final SpecificationBuilder<Root> builder = new SpecificationBuilder<>(null, false, Database.H2);
@Test
void singularStringAttribute() {
final Root root1 = rootRepository.save(new Root().setStrValue("rootx"));
final Root root2 = rootRepository.save(new Root().setStrValue("rootx"));
final Root root3 = rootRepository.save(new Root().setStrValue("rooty"));
final Root root4 = rootRepository.save(new Root().setStrValue("rooty"));
final Root root5 = rootRepository.save(new Root()); // null
assertThat(filter("strValue==rootx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue==nostr")).isEmpty();
if (getRsqlToSpecBuilder() != LEGACY_G1) {
assertThat(filter("strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
}
assertThat(filter("strValue!=rootx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("strValue!=nostr")).hasSize(5);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
assertThat(filter("strValue=not=null")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
}
assertThat(filter("strValue<rooty")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue<=rooty")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue>rootx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("strValue>=rootx")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue=in=rootx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue=in=(rootx, rooty)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue=in=(rootz, roott)")).isEmpty();
assertThat(filter("strValue=out=rootx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("strValue=out=(rootx, rooty)")).hasSize(1).containsExactlyInAnyOrder(root5);
// wildcard, like
assertThat(filter("strValue==root*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue==*tx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue!=root*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("strValue!=*tx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("strValue==*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root5);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// null checks
assertThat(filter("strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("strValue=not=null")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
}
assertThat(filter("strValue==*tx and strValue==rooty")).isEmpty();
assertThat(filter("strValue==*tx and strValue!=rootx")).isEmpty();
assertThat(filter("strValue==*tx and strValue==rootx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("strValue==*tx or strValue==rooty")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("strValue==*tx or strValue!=rootx")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
assertThat(filter("strValue==*tx or strValue=is=null")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
}
@Test
void singularIntAttribute() {
final Root root1 = rootRepository.save(new Root().setIntValue(0));
final Root root2 = rootRepository.save(new Root().setIntValue(0));
final Root root3 = rootRepository.save(new Root().setIntValue(1));
final Root root4 = rootRepository.save(new Root().setIntValue(1));
assertThat(filter("intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue==2")).isEmpty();
assertThat(filter("intValue!=0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("intValue!=2")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue<1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("intValue>=0")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue=in=(2, 3)")).isEmpty();
assertThat(filter("intValue=out=0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("intValue=out=(0, 1)")).isEmpty();
assertThat(filter("intValue==0 and intValue==1")).isEmpty();
assertThat(filter("intValue==0 and intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue==0 and intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("intValue==0 or intValue==1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("intValue==0 or intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
}
@Test
void singularEntityAttribute() {
final Sub sub1 = subRepository.save(new Sub().setStrValue("subx").setIntValue(0));
final Sub sub2 = subRepository.save(new Sub().setStrValue("suby").setIntValue(1));
final Root root1 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root2 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root3 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root4 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root5 = rootRepository.save(new Root()); // no sub set
// by sub entity string
assertThat(filter("subEntity.strValue==subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue==nostr")).isEmpty();
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it is
assertThat(filter("subEntity.strValue!=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.strValue!=nostr")).hasSize(5);
assertThat(filter("subEntity.strValue<suby")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue<=suby")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue>subx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.strValue>=subx")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue=in=subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue=in=(subx, suby)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue=in=(subZ, subT)")).isEmpty();
assertThat(filter("subEntity.strValue=out=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.strValue=out=(subx, suby)")).hasSize(1).containsExactlyInAnyOrder(root5);
// wildcard, like
assertThat(filter("subEntity.strValue==sub*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue==*bx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.strValue!=*bx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.strValue==*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root5);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// null checks
assertThat(filter("subEntity.strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.strValue=not=null")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
}
assertThat(filter("subEntity.strValue==*bx and subEntity.strValue==suby")).isEmpty();
assertThat(filter("subEntity.strValue==*bx and subEntity.strValue!=subx")).isEmpty();
assertThat(filter("subEntity.strValue==*bx and subEntity.strValue==subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue==*bx or subEntity.strValue==suby"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 doesn't get null entity in not, and doesn't support =is= and =not=
assertThat(filter("subEntity.strValue==*bx or subEntity.strValue!=subx"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subEntity.strValue==*bx or subEntity.strValue=is=null")).hasSize(3)
.containsExactlyInAnyOrder(root1, root2, root5);
}
// by sub entity int
assertThat(filter("subEntity.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue==2")).isEmpty();
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 doesn't get null entity in not
assertThat(filter("subEntity.intValue!=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.intValue!=2")).hasSize(5);
}
assertThat(filter("subEntity.intValue<1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.intValue>=0")).hasSize(4);
assertThat(filter("subEntity.intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.intValue=in=(2, 3)")).isEmpty();
assertThat(filter("subEntity.intValue=out=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.intValue=out=(0, 1)")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.intValue==0 and subEntity.intValue==1")).isEmpty();
assertThat(filter("subEntity.intValue==0 and subEntity.intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue==0 and subEntity.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.intValue==0 or subEntity.intValue==1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 doesn't get null entity in not
assertThat(filter("subEntity.intValue==0 or subEntity.intValue!=1")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
assertThat(filter("subEntity.strValue==subx and subEntity.intValue==1")).isEmpty();
assertThat(filter("subEntity.strValue==subx and subEntity.intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue==subx and subEntity.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.strValue==subx or subEntity.intValue==1")).hasSize(4)
.containsExactlyInAnyOrder(root1, root2, root3, root4);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 doesn't get null entity in not
assertThat(filter("subEntity.strValue==subx or subEntity.intValue!=1")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
}
@Test
void pluralSubSetAttribute() {
final Sub sub1 = subRepository.save(new Sub().setStrValue("subx").setIntValue(0));
final Sub sub2 = subRepository.save(new Sub().setStrValue("suby").setIntValue(1));
final Sub sub3 = subRepository.save(new Sub().setStrValue("suby").setIntValue(0));
final Root root1 = rootRepository.save(new Root().setSubSet(Set.of(sub1)));
final Root root2 = rootRepository.save(new Root().setSubSet(Set.of(sub2)));
final Root root3 = rootRepository.save(new Root().setSubSet(Set.of(sub3)));
final Root root4 = rootRepository.save(new Root().setSubSet(Set.of(sub1, sub2)));
final Root root5 = rootRepository.save(new Root().setSubSet(Set.of(sub1, sub3)));
final Root root6 = rootRepository.save(new Root()); // no sub set
// by sub entity string
assertThat(filter("subSet.strValue==subx")).hasSize(3).containsExactlyInAnyOrder(root1, root4, root5);
assertThat(filter("subSet.strValue==nostr")).isEmpty();
assertThat(filter("subSet.strValue!=subx")).hasSize(3).containsExactlyInAnyOrder(root2, root3, root6);
assertThat(filter("subSet.strValue!=nostr")).hasSize(6);
assertThat(filter("subSet.strValue<suby")).hasSize(3).containsExactlyInAnyOrder(root1, root4, root5);
assertThat(filter("subSet.strValue<=suby")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue>subx")).hasSize(4).containsExactlyInAnyOrder(root2, root3, root4, root5);
assertThat(filter("subSet.strValue>=subx")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue=in=subx")).hasSize(3).containsExactlyInAnyOrder(root1, root4, root5);
assertThat(filter("subSet.strValue=in=(subx, suby)")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue=in=(subZ, subT)")).isEmpty();
assertThat(filter("subSet.strValue=out=subx")).hasSize(3).containsExactlyInAnyOrder(root2, root3, root6);
assertThat(filter("subSet.strValue=out=(subx, suby)")).hasSize(1).containsExactlyInAnyOrder(root6);
// wildcard, like
assertThat(filter("subSet.strValue==sub*")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue==*bx")).hasSize(3).containsExactlyInAnyOrder(root1, root4, root5);
assertThat(filter("subSet.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root6);
assertThat(filter("subSet.strValue!=*bx")).hasSize(3).containsExactlyInAnyOrder(root2, root3, root6);
assertThat(filter("subSet.strValue==*")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root6);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 treats it like same element shall match - which doesn't match to "has" semantic for sets
assertThat(filter("subSet.strValue==*bx and subSet.strValue==suby")).hasSize(2).containsExactlyInAnyOrder(root4, root5);
}
assertThat(filter("subSet.strValue==*bx and subSet.strValue!=subx")).isEmpty();
assertThat(filter("subSet.strValue==*bx and subSet.strValue!=suby")).hasSize(1).containsExactlyInAnyOrder(root1);
assertThat(filter("subSet.strValue==*bx or subSet.strValue==suby"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 doesn't get null - has which shall be included as doesn't have
assertThat(filter("subSet.strValue==*bx or subSet.strValue!=subx"))
.hasSize(6).containsExactlyInAnyOrder(root1, root2, root3, root4, root5, root6);
}
// by sub entity int
assertThat(filter("subSet.intValue==0")).hasSize(4).containsExactlyInAnyOrder(root1, root3, root4, root5);
assertThat(filter("subSet.intValue==2")).isEmpty();
if (getRsqlToSpecBuilder() == G3) {
// the legacy builders has different (and wrong semantic)
// G1 - has element != x
// G2 - has element != x or has no elements
// accompanying G3 it is (as it should be) semantic of set != x - has no element with value x (including nhas no elements)
assertThat(filter("subSet.intValue!=0")).hasSize(2).containsExactlyInAnyOrder(root2, root6);
}
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 doesn't get null - has which shall be included as doesn't have
assertThat(filter("subSet.intValue!=2")).hasSize(6);
}
assertThat(filter("subSet.intValue<1")).hasSize(4).containsExactlyInAnyOrder(root1, root3, root4, root5);
assertThat(filter("subSet.intValue<=1")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root2, root4);
assertThat(filter("subSet.intValue>=0")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.intValue=in=0")).hasSize(4).containsExactlyInAnyOrder(root1, root3, root4, root5);
assertThat(filter("subSet.intValue=in=(0, 1)")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subSet.intValue=in=(2, 3)")).isEmpty();
assertThat(filter("subSet.intValue=out=0")).hasSize(2).containsExactlyInAnyOrder(root2, root6);
assertThat(filter("subSet.intValue=out=(0, 1)")).hasSize(1).containsExactlyInAnyOrder(root6);
assertThat(filter("subSet.intValue==0 and subSet.intValue==0")).hasSize(4).containsExactlyInAnyOrder(root1, root3, root4, root5);
if (getRsqlToSpecBuilder() == G3) {
assertThat(filter("subSet.intValue==0 and subSet.intValue!=1")).hasSize(3).containsExactlyInAnyOrder(root1, root3, root5);
}
assertThat(filter("subSet.intValue==0 or subSet.intValue==1"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 doesn't get null - has which shall be included as doesn't have
assertThat(filter("subSet.intValue==0 or subSet.intValue!=1"))
.hasSize(5).containsExactlyInAnyOrder(root1, root3, root4, root5, root6);
}
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 treats it like same element shall match - which doesn't match to "has" semantic for sets
assertThat(filter("subSet.intValue==0 and subSet.strValue==suby")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
}
assertThat(filter("subSet.intValue==0 and subSet.strValue!=subx")).hasSize(1).containsExactlyInAnyOrder(root3);
assertThat(filter("subSet.intValue==0 and subSet.strValue!=suby")).hasSize(1).containsExactlyInAnyOrder(root1);
assertThat(filter("subSet.intValue==0 or subSet.strValue==suby"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// G1 doesn't get null - has which shall be included as doesn't have
assertThat(filter("subSet.intValue==0 or subSet.strValue!=subx"))
.hasSize(6).containsExactlyInAnyOrder(root1, root2, root3, root4, root5, root6);
}
}
@Test
void pluralSubMapAttribute() {
final Root root1 = rootRepository.save(new Root().setSubMap(Map.of("x", "rootx", "y", "rooty")));
final Root root2 = rootRepository.save(new Root().setSubMap(Map.of("x", "rootx", "y", "rootx")));
final Root root3 = rootRepository.save(new Root().setSubMap(Map.of("x", "rooty", "y", "rooty")));
final Root root4 = rootRepository.save(new Root().setSubMap(Map.of("x", "rooty", "y", "rootx")));
final Root root5 = rootRepository.save(new Root().setSubMap(Map.of("x", "rootx")));
final Root root6 = rootRepository.save(new Root()); // no sub map
assertThat(filter("subMap.x==rootx")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subMap.x==nostr")).isEmpty();
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it's not
assertThat(filter("subMap.x!=rootx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x!=nostr")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x<rooty")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subMap.x<=rooty")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x>rootx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x>=rootx")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x=in=rootx")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subMap.x=in=(rootx, rooty)")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x=in=(rootz, roott)")).isEmpty();
assertThat(filter("subMap.x=out=rootx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x=out=(rootx, rooty)")).isEmpty();
// wildcard, like
assertThat(filter("subMap.x==root*")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x==*tx")).hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subMap.x!=root*")).isEmpty();
assertThat(filter("subMap.x!=*tx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subMap.x==*")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x!=*")).isEmpty();
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// null checks
assertThat(filter("subMap.x=is=null")).hasSize(1).containsExactlyInAnyOrder(root6);
assertThat(filter("subMap.x=not=null")).hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
}
assertThat(filter("subMap.x==*tx and subMap.y==rooty")).hasSize(1).containsExactlyInAnyOrder(root1);
assertThat(filter("subMap.x==*tx and subMap.y!=rootx")).hasSize(1).containsExactlyInAnyOrder(root1);
assertThat(filter("subMap.x==*tx or subMap.x==rooty"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subMap.x==*tx or subMap.x!=rootx"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
}
@Test
void singularEntitySubSubAttribute() {
final SubSub subSub1 = subSubRepository.save(new SubSub().setStrValue("subx").setIntValue(0));
final SubSub subSub2 = subSubRepository.save(new SubSub().setStrValue("suby").setIntValue(1));
final Sub sub1 = subRepository.save(new Sub().setSubSub(subSub1));
final Sub sub2 = subRepository.save(new Sub().setSubSub(subSub2));
final Root root1 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root2 = rootRepository.save(new Root().setSubEntity(sub1));
final Root root3 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root4 = rootRepository.save(new Root().setSubEntity(sub2));
final Root root5 = rootRepository.save(new Root()); // no sub set
// by sub entity string
assertThat(filter("subEntity.subSub.strValue==subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue==nostr")).isEmpty();
// TODO / recheck - when missing entity shall it be included or not in != or =out=? - now it is
assertThat(filter("subEntity.subSub.strValue!=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.strValue!=nostr")).hasSize(5);
assertThat(filter("subEntity.subSub.strValue<suby")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue<=suby")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue>subx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.subSub.strValue>=subx")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue=in=subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue=in=(subx, suby)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue=in=(subZ, subT)")).isEmpty();
assertThat(filter("subEntity.subSub.strValue=out=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.strValue=out=(subx, suby)")).hasSize(1).containsExactlyInAnyOrder(root5);
// wildcard, like
assertThat(filter("subEntity.subSub.strValue==sub*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue==*bx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.strValue!=*bx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.strValue==*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root5);
if (getRsqlToSpecBuilder() != LEGACY_G1) {
// null checks
if (getRsqlToSpecBuilder() != LEGACY_G2) {
assertThat(filter("subEntity.subSub.strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
}
assertThat(filter("subEntity.subSub.strValue=not=null")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
}
assertThat(filter("subEntity.subSub.strValue==*bx and subEntity.subSub.strValue==suby")).isEmpty();
assertThat(filter("subEntity.subSub.strValue==*bx and subEntity.subSub.strValue!=subx")).isEmpty();
assertThat(filter("subEntity.subSub.strValue==*bx and subEntity.subSub.strValue==subx"))
.hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue==*bx or subEntity.subSub.strValue==suby"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
if (getRsqlToSpecBuilder() != LEGACY_G1 && getRsqlToSpecBuilder() != LEGACY_G2) {
// G1 doesn't get null entity in not, and doesn't support =is= and =not=
assertThat(filter("subEntity.subSub.strValue==*bx or subEntity.subSub.strValue!=subx"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subEntity.subSub.strValue==*bx or subEntity.subSub.strValue=is=null"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
// by sub entity int
assertThat(filter("subEntity.subSub.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue==2")).isEmpty();
if (getRsqlToSpecBuilder() != LEGACY_G1 && getRsqlToSpecBuilder() != LEGACY_G2) {
// G1 doesn't get null entity in not
assertThat(filter("subEntity.subSub.intValue!=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.intValue!=2")).hasSize(5);
}
assertThat(filter("subEntity.subSub.intValue<1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.subSub.intValue>=0")).hasSize(4);
assertThat(filter("subEntity.subSub.intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.intValue=in=(2, 3)")).isEmpty();
assertThat(filter("subEntity.subSub.intValue=out=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.intValue=out=(0, 1)")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.intValue==0 and subEntity.subSub.intValue==1")).isEmpty();
assertThat(filter("subEntity.subSub.intValue==0 and subEntity.subSub.intValue!=1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue==0 and subEntity.subSub.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.intValue==0 or subEntity.subSub.intValue==1")).hasSize(4)
.containsExactlyInAnyOrder(root1, root2, root3, root4);
if (getRsqlToSpecBuilder() != LEGACY_G1 && getRsqlToSpecBuilder() != LEGACY_G2) {
// G1 doesn't get null entity in not
assertThat(filter("subEntity.subSub.intValue==0 or subEntity.subSub.intValue!=1"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
assertThat(filter("subEntity.subSub.strValue==subx and subEntity.subSub.intValue==1")).isEmpty();
assertThat(filter("subEntity.subSub.strValue==subx and subEntity.subSub.intValue!=1")).hasSize(2)
.containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue==subx and subEntity.subSub.intValue==0"))
.hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.strValue==subx or subEntity.subSub.intValue==1"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
if (getRsqlToSpecBuilder() != LEGACY_G1 && getRsqlToSpecBuilder() != LEGACY_G2) {
// G1 doesn't get null entity in not
assertThat(filter("subEntity.subSub.strValue==subx or subEntity.subSub.intValue!=1"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
}
private List<Root> filter(final String rsql) {
// reference / auto filter (using elements and reflection)
final EntityMatcher matcher = EntityMatcher.forRsql(rsql);
final List<Root> refResult = StreamSupport.stream(rootRepository.findAll().spliterator(), false).filter(matcher::match).toList();
final List<Root> result = rootRepository.findAll(getSpecification(rsql));
// auto check with reference result
try {
assertThat(result).containsExactlyInAnyOrder(refResult.toArray(Root[]::new));
} catch (final AssertionError e) {
log.error(
"Fail to get expected result for RSQL: {} with SQL query: {}",
rsql, new HawkbitQlToSql(entityManager).toSQL(Root.class, null, rsql, G3),
e);
throw e;
}
return result;
}
protected Specification<Root> getSpecification(final String rsql) {
return builder.specification(RsqlParser.parse(rsql));
}
private static RsqlConfigHolder.RsqlToSpecBuilder getRsqlToSpecBuilder() {
return RsqlConfigHolder.getInstance().getRsqlToSpecBuilder();
}
@SpringBootConfiguration
static class Config {}
}

View File

@@ -0,0 +1,40 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import lombok.Data;
import lombok.experimental.Accessors;
@Entity
@Data
@Accessors(chain = true)
class Sub {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// singular attributes
// basic
private String strValue;
private int intValue;
// entity
@ManyToOne
private SubSub subSub;
}

View File

@@ -0,0 +1,17 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SubRepository extends CrudRepository<Sub, Long>, JpaSpecificationExecutor<Sub> {}

View File

@@ -0,0 +1,33 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;
import lombok.experimental.Accessors;
@Entity
@Data
@Accessors(chain = true)
class SubSub {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// singular attributes
// basic
private String strValue;
private int intValue;
}

View File

@@ -0,0 +1,17 @@
/**
* 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 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.eclipse.hawkbit.repository.jpa.rsql.sa;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SubSubRepository extends CrudRepository<SubSub, Long>, JpaSpecificationExecutor<SubSub> {}