Extract QL support in a top level module (#2808)

Signed-off-by: Avgustin Marinov <Avgustin.Marinov@bosch.com>
This commit is contained in:
Avgustin Marinov
2025-11-14 14:19:36 +02:00
committed by GitHub
parent df4464406e
commit c5ea265e0f
71 changed files with 454 additions and 485 deletions

3
hawkbit-ql-jpa/README.md Normal file
View File

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

65
hawkbit-ql-jpa/pom.xml Normal file
View File

@@ -0,0 +1,65 @@
<!--
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-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>hawkbit-ql-jpa</artifactId>
<name>hawkBit :: Query Language :: JPA</name>
<dependencies>
<dependency>
<groupId>org.eclipse.persistence</groupId>
<artifactId>org.eclipse.persistence.jpa</artifactId>
<version>${eclipselink.version}</version>
<optional>true</optional>
</dependency>
<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>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,313 @@
/**
* 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.ql;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.EQ;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_LIKE;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.Collection;
import java.util.Map;
import java.util.Objects;
import java.util.function.BiPredicate;
import org.eclipse.hawkbit.ql.Node.Comparison.Operator;
import org.springframework.core.ResolvableType;
/**
* 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 final boolean ignoreCase;
private EntityMatcher(final Node root, final boolean ignoreCase) {
this.root = root;
this.ignoreCase = ignoreCase;
}
public static EntityMatcher of(final Node root) {
return of(root, false);
}
public static EntityMatcher of(final Node root, final boolean ignoreCase) {
return new EntityMatcher(root, ignoreCase);
}
public <T> boolean match(final T t) {
return match(t, root);
}
@SuppressWarnings({ "java:S3776", "java:S3358", "java:S1125", "java:S6541" }) // better readable this way
private <T> boolean match(final T t, final Node node) {
if (node instanceof Node.Comparison comparison) {
final String[] split = comparison.getKey().split("\\.", 2);
try {
final Getter fieldGetter = getGetter(t.getClass(), split[0]);
final Object fieldValue = fieldGetter.get(t);
final Operator op = comparison.getOp();
if (Map.class.isAssignableFrom(getReturnType(fieldGetter))) {
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 compareIgnoreCaseAware(
fieldValue == null ? null : ((Map<?, ?>) fieldValue).get(split[1]),
op,
map(
comparison.getValue(),
(Class<?>) ((ParameterizedType) fieldGetter.type()).getActualTypeArguments()[1]));
} else if (Collection.class.isAssignableFrom(getReturnType(fieldGetter))) { // Set / List
final Object value;
final BiPredicate<Object, Operator> compare;
if (split.length == 1) {
value = map(comparison.getValue(), getReturnType(fieldGetter));
compare = (e, operator) -> compareIgnoreCaseAware(e, operator, value);
} else {
final Getter valueGetter = getGetter(
(Class<?>) ((ParameterizedType) fieldGetter.type()).getActualTypeArguments()[0], split[1]);
value = map(comparison.getValue(), getReturnType(valueGetter));
compare = (e, operator) -> {
try {
return compareIgnoreCaseAware(
map(e == null ? null : valueGetter.get(e), getReturnType(valueGetter)), 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.test(e, op));
case NE, NOT_IN, NOT_LIKE -> set == null
? true
: set.stream().noneMatch(e -> compare.test(e, op == NE ? EQ : op == NOT_IN ? IN : LIKE));
};
} else {
if (split.length == 1) {
return compareIgnoreCaseAware(fieldValue, op, map(comparison.getValue(), getReturnType(fieldGetter)));
} else {
if (split[1].contains(".")) {
// nested field access
final String[] nestedSplit = split[1].split("\\.", 2);
final Getter nestedFieldGetter = getGetter(getReturnType(fieldGetter), nestedSplit[0]);
final Getter valueGetter = getGetter(getReturnType(nestedFieldGetter), nestedSplit[1]);
final Object nestedFieldValue = fieldValue == null ? null : nestedFieldGetter.get(fieldValue);
return compareIgnoreCaseAware(
nestedFieldValue == null ? null : valueGetter.get(nestedFieldValue),
op,
map(comparison.getValue(), getReturnType(valueGetter)));
} else {
final Getter valueGetter = getGetter(getReturnType(fieldGetter), split[1]);
return compareIgnoreCaseAware(
fieldValue == null ? null : valueGetter.get(fieldValue),
op,
map(comparison.getValue(), getReturnType(valueGetter)));
}
}
}
} 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 boolean compareIgnoreCaseAware(final Object entityValue, final Operator op, final Object comparisonValue) {
return compare(ignoreCase(entityValue), op, ignoreCase(comparisonValue));
}
private Object ignoreCase(final Object o) {
if (!ignoreCase || o == null) {
return o;
}
// if here - ignoreCase in true and we have non-null value
if (o instanceof String str) {
return str.toLowerCase();
} else if (o instanceof Collection<?> collection) {
return collection.stream().map(this::ignoreCase).toList();
} else {
return o;
}
}
// java:S3011 uses reflection to private members anyway
// java:S3358 - better readable this way
@SuppressWarnings({ "java:S3011", "java:S3358" })
private static <T> Getter getGetter(final Class<T> t, final String fieldName) throws NoSuchMethodException {
final String[] parts = fieldName.split("\\.");
if (parts.length > 1) {
final Getter firstGetter = getGetter(t, parts[0]);
final Getter nextGetter = getGetter(t, fieldName.substring(parts[0].length() + 1));
return new Getter() {
@Override
public Object get(final Object obj) throws IllegalAccessException, InvocationTargetException {
return nextGetter.get(firstGetter.get(obj));
}
@Override
public Type type() {
return nextGetter.type();
}
};
}
final String getterLowercase = "get" + fieldName.toLowerCase();
return Arrays.stream(t.getMethods())
.filter(method -> getterLowercase.equals(method.getName().toLowerCase()))
.findFirst()
.map(Method::getName)
.map(getterName -> {
try {
// gets method via Class.getMethod(String, Class<?>...) because in listing it might have not
// the correct return type, but the type got from a declaring generic type
final Method getter = t.getMethod(getterName);
getter.setAccessible(true);
return new Getter() {
@Override
public Object get(final Object obj) throws IllegalAccessException, InvocationTargetException {
return getter.invoke(obj);
}
@Override
public Type type() {
final Type type = getter.getGenericReturnType();
return type instanceof Class<?>
? type
: type instanceof ParameterizedType
? type // Map or Collection generic type
: ResolvableType.forMethodReturnType(getter, t).resolve();
}
};
} catch (final NoSuchMethodException e) {
throw new IllegalStateException("Unexpected: No getter found for field: " + fieldName + " in class: " + t.getName(), e);
}
}).orElseThrow(() -> new NoSuchMethodException("No getter found for field: " + fieldName + " in class: " + t.getName()));
}
private static Class<?> getReturnType(final Getter getter) {
return getter.type() instanceof Class<?> clazz ? clazz : (Class<?>) ((ParameterizedType) getter.type()).getRawType();
}
@SuppressWarnings({ "unchecked", "rawtypes", "java:S3776" }) // java:S3776 - better readable this way
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 entityValue, final Operator op, final Object comparisonValue) {
if ((entityValue == null || comparisonValue == null) && // null is not comparable!
(op == GT || op == GTE || op == LT || op == LTE)) {
return false;
}
return switch (op) {
case EQ -> Objects.equals(entityValue, comparisonValue);
case NE -> !Objects.equals(entityValue, comparisonValue);
case GT -> compare(entityValue, comparisonValue) > 0;
case GTE -> compare(entityValue, comparisonValue) >= 0;
case LT -> compare(entityValue, comparisonValue) < 0;
case LTE -> compare(entityValue, comparisonValue) <= 0;
case IN -> in(entityValue, comparisonValue);
case NOT_IN -> !in(entityValue, comparisonValue);
case LIKE -> like(comparisonValue, entityValue);
case NOT_LIKE -> !like(comparisonValue, entityValue);
};
}
@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()));
}
}
private interface Getter {
Object get(Object obj) throws IllegalAccessException, InvocationTargetException;
Type type();
}
}

View File

@@ -0,0 +1,231 @@
/**
* 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.ql;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.function.UnaryOperator;
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);
}
// utility method that maps this node with a transformer that could modify comparisons - e.g. change keys, values, operators, or whatever
// if there are no changes the same instance is returned
default Node transform(final UnaryOperator<Comparison> transformer) {
if (this instanceof Comparison comparison) {
return transformer.apply(comparison);
} else {
final List<Node> mappedChildren = new ArrayList<>();
boolean modified = false;
for (final Node child : ((Logical) this).getChildren()) {
final Node mapped = child.transform(transformer);
mappedChildren.add(mapped);
if (!mapped.equals(child)) {
modified = true;
}
}
return modified ? new Logical(((Logical) this).getOp(), mappedChildren) : this;
}
}
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(final 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,44 @@
/**
* 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
*/
package org.eclipse.hawkbit.ql;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.ToString;
/**
* Exception used by the REST API in case of RSQL search filter query.
*/
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class QueryException extends RuntimeException {
public enum ErrorCode {
INVALID_SYNTAX,
UNSUPPORTED_FIELD,
GENERIC // an other
}
@Getter
private final ErrorCode errorCode;
public QueryException(final ErrorCode errorCode, final String message) {
this(errorCode, message, null);
}
public QueryException(final ErrorCode errorCode, final Throwable cause) {
this(errorCode, null, cause);
}
public QueryException(final ErrorCode errorCode, final String message, final Throwable cause) {
super(message, cause);
this.errorCode = errorCode;
}
}

View File

@@ -0,0 +1,71 @@
/**
* 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
*/
package org.eclipse.hawkbit.ql;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import lombok.NonNull;
/**
* A query field interface extended by all the fields that could be used in queries.
*/
public interface QueryField {
/**
* Separator for the sub attributes
*/
String SUB_ATTRIBUTE_SEPARATOR = ".";
String SUB_ATTRIBUTE_SPLIT_REGEX = "\\" + SUB_ATTRIBUTE_SEPARATOR;
/**
* Returns the entity field name, e.g. the JPA entity field name.
*
* @return the string representation of the underlying persistence field name.
*/
@NonNull
String getName();
/**
* @return all sub entities attributes.
*/
default List<String> getSubEntityAttributes() {
return Collections.emptyList();
}
/**
* Return the default sub entity attribute if available. This allows to skip that sub-attribute and call with "shortcut" - the enum name
*
* @return the default sub-attribute or <code>null</code>> if no default is available
*/
default String getDefaultSubEntityAttribute() {
final List<String> subAttributes = getSubEntityAttributes();
return subAttributes.size() == 1 ? subAttributes.get(0) : null;
}
/**
* Returns the name of the field, that identifies the entity.
*
* @return the name of the identifier, by default 'id'
*/
default String getIdentifierFieldName() {
return "id";
}
/**
* Is the entity field a {@link Map} consisting of key-value pairs.
*
* @return <code>true</code> is a map <code>false</code> is not a map
*/
default boolean isMap() {
return false;
}
}

View File

@@ -0,0 +1,244 @@
/**
* 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.ql.jpa;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.ql.EntityMatcher;
import org.eclipse.hawkbit.ql.Node;
import org.eclipse.hawkbit.ql.Node.Comparison;
import org.eclipse.hawkbit.ql.QueryException;
import org.eclipse.hawkbit.ql.QueryField;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.data.jpa.domain.Specification;
/**
* 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. It has out of the box support for the 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
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@SuppressWarnings("java:S6548") // singleton holder ensures static access to spring resources in some places
public class QLSupport implements ApplicationListener<ContextRefreshedEvent> {
private static final QLSupport SINGLETON = new QLSupport();
/**
* If QL comparison operators shall ignore the case. If ignore case is <code>true</code> "x == ax" will match "x == aX"
*/
@Value("${hawkbit.ql.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.
* <p/>
* 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.ql.case-insensitive-db:false}")
private boolean caseInsensitiveDB;
private QueryParser parser;
private List<NodeTransformer> nodeTransformers = List.of();
private EntityManager entityManager;
/**
* @return The holder singleton instance.
*/
public static QLSupport getInstance() {
return SINGLETON;
}
@Autowired
public void setQueryParser(final QueryParser parser) {
this.parser = parser;
}
@Override
public void onApplicationEvent(@NonNull final ContextRefreshedEvent event) {
nodeTransformers = event.getApplicationContext().getBeansOfType(NodeTransformer.class).values().stream().sorted((b1, b2) -> {
final Order o1 = b1.getClass().getAnnotation(Order.class);
final Order o2 = b2.getClass().getAnnotation(Order.class);
return Integer.compare(o1 != null ? o1.value() : Ordered.LOWEST_PRECEDENCE, o2 != null ? o2.value() : Ordered.LOWEST_PRECEDENCE);
}).toList();
}
@Autowired
void setEntityManager(final EntityManager entityManager) {
this.entityManager = entityManager;
}
public Node parse(final String query) {
return parse(query, null);
}
public <A extends Enum<A> & QueryField> Node parse(final String query, final Class<A> queryFieldType) {
return parser.parse(ignoreCase || caseInsensitiveDB ? query.toLowerCase() : query, queryFieldType);
}
/**
* 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 query the rsql query to be parsed
* @param queryFieldType the enum class type which implements the {@link QueryField}
* @return a specification which can be used with JPA
* @throws QueryException if the RSQL syntax is wrong or if a field in the RSQL string is used but not provided by the given {@code queryFieldType}
*/
public <A extends Enum<A> & QueryField, T> Specification<T> buildSpec(final String query, final Class<A> queryFieldType) {
return new SpecificationBuilder<T>(ignoreCase && !caseInsensitiveDB)
.specification(transform(parse(query, queryFieldType), queryFieldType));
}
public <A extends Enum<A> & QueryField, T> Specification<T> buildSpec(final Node query, final Class<A> queryFieldType) {
return new SpecificationBuilder<T>(ignoreCase && !caseInsensitiveDB).specification(transform(query, queryFieldType));
}
@SuppressWarnings("java:S1117") // it is again ignoreCase
public <A extends Enum<A> & QueryField> EntityMatcher entityMatcher(final String query, final Class<A> queryFieldType) {
return EntityMatcher.of(transform(parse(query, queryFieldType), queryFieldType), ignoreCase || caseInsensitiveDB);
}
/**
* Validates the query string
*
* @param query query string to validate
* @param queryFieldType the enum class type which implements the {@link QueryField}
* @param jpaType the JPA entity type to validate against
* @throws QueryException if query is invalid
*/
@SuppressWarnings({ "unchecked", "rawtypes" })
public <A extends Enum<A> & QueryField> void validate(final String query, final Class<A> queryFieldType, final Class<?> jpaType) {
final CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
final CriteriaQuery<?> criteriaQuery = criteriaBuilder.createQuery(jpaType);
buildSpec(query, queryFieldType).toPredicate(criteriaQuery.from((Class) jpaType), criteriaQuery, criteriaBuilder);
}
private <A extends Enum<A> & QueryField> Node transform(Node node, final Class<A> queryFieldType) {
for (final NodeTransformer transformer : nodeTransformers) {
node = transformer.transform(node, queryFieldType);
}
return node;
}
/**
* By registering a custom {@link QueryParser} (as a {@link org.springframework.context.annotation.Bean}) the entire parsing of the queries
* could be replaced / customized, e.g. the default query language (RSQL) could be replaced with a custom.
*/
public interface QueryParser {
<T extends Enum<T> & QueryField> Node parse(final String query, final Class<T> queryFieldType) throws QueryException;
}
/**
* By registering a custom {@link NodeTransformer} (as a {@link org.springframework.context.annotation.Bean}) the nodes could be
* modified after parsing, e.g. to add implicit nodes or to modify values.
* <p/>
* By default, all transformers are with {@link Ordered#LOWEST_PRECEDENCE} order. So, if you need a specific order use the {@link Order}
* annotation of their class (not on the bean registering methods).
*/
public interface NodeTransformer {
<T extends Enum<T> & QueryField> Node transform(Node node, final Class<T> queryFieldType);
/**
* Base implementation that does no real transformation but allows extenders to easily modify keys and / or values by simply extending
* the extension points.
*/
abstract class Abstract implements NodeTransformer {
public <T extends Enum<T> & QueryField> Node transform(final Node node, final Class<T> queryFieldType) {
return node.transform(comparison -> transform(comparison, queryFieldType));
}
protected <T extends Enum<T> & QueryField> Comparison transform(final Comparison comparison, final Class<T> queryFieldType) {
final String key = transformKey(comparison.getKey(), comparison, queryFieldType).toString();
final Object value = transformValue(comparison.getValue(), comparison, queryFieldType);
return key.equals(comparison.getKey()) && Objects.equals(value, comparison.getValue())
? comparison : Comparison.builder().key(key).op(comparison.getOp()).value(value).build();
}
// just extension points for subclasses
@SuppressWarnings("java:S1172") // comparison and queryFieldType might be useful for subclasses
protected <T extends Enum<T> & QueryField> Object transformKey(
final String key, final Comparison comparison, final Class<T> queryFieldType) {
return key;
}
// internal, override only if you really want to replace whole lists
protected <T extends Enum<T> & QueryField> Object transformValue(
final Object value, final Comparison comparison, final Class<T> queryFieldType) {
if (value instanceof List<?> list) {
final List<Object> mappedList = new ArrayList<>();
boolean modified = false;
for (final Object e : list) {
final Object mapped = transformValueElement(e, comparison, queryFieldType);
if (!Objects.equals(mapped, value)) {
modified = true;
}
mappedList.add(mapped);
}
return modified ? mappedList : list;
} else {
return transformValueElement(value, comparison, queryFieldType);
}
}
// just extension points for subclasses
@SuppressWarnings("java:S1172") // comparison and queryFieldType might be useful for subclasses
protected <T extends Enum<T> & QueryField> Object transformValueElement(
final Object value, final Comparison comparison, final Class<T> queryFieldType) {
return value;
}
}
}
}

View File

@@ -0,0 +1,510 @@
/**
* 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.ql.jpa;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_LIKE;
import static org.eclipse.hawkbit.ql.QueryException.ErrorCode.INVALID_SYNTAX;
import static org.eclipse.hawkbit.ql.QueryException.ErrorCode.UNSUPPORTED_FIELD;
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 java.util.stream.Collectors;
import jakarta.annotation.Nonnull;
import jakarta.persistence.PersistenceException;
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.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.hawkbit.ql.Node;
import org.eclipse.hawkbit.ql.Node.Comparison;
import org.eclipse.hawkbit.ql.Node.Comparison.Operator;
import org.eclipse.hawkbit.ql.Node.Logical;
import org.eclipse.hawkbit.ql.QueryException;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.ObjectUtils;
@Slf4j
public class SpecificationBuilder<T> {
private static final char ESCAPE_CHAR = '\\';
private final boolean ensureIgnoreCase;
public SpecificationBuilder(final boolean ensureIgnoreCase) {
this.ensureIgnoreCase = ensureIgnoreCase;
}
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) {
final Map<String, Integer> state = pathResolver.getState();
return cb.or(logical.getChildren().stream()
.map(child -> {
pathResolver.reset(state);
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("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 QueryException(
UNSUPPORTED_FIELD,
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(toMapValuePath(pathResolver.getJoinOn(attribute, split[1])));
case NE -> cb.isNotNull(toMapValuePath(pathResolver.getJoinOn(attribute, split[1])));
default -> throw new QueryException(
INVALID_SYNTAX,
String.format("Operator %s is not supported for map fields with value null", op));
};
} else {
final MapJoin<?, ?, ?> mapPath = (MapJoin<?, ?, ?>) pathResolver.getPath(attribute);
return isNot(op)
? compare(comparison, toMapValuePath(pathResolver.getJoinOnInner(attribute, split[1])))
: cb.and(equal(mapPath.key(), split[1]), compare(comparison, toMapValuePath(mapPath)));
}
} else if (attribute instanceof SetAttribute<?, ?> setAttribute) {
if (split.length < 2 || ObjectUtils.isEmpty(split[1])) {
throw new QueryException(
UNSUPPORTED_FIELD,
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);
}
}
@SuppressWarnings("unchecked")
private static Path<String> toMapValuePath(final Path<?> mapJoin) {
final Path<?> valuePath = ((MapJoin<?, ?, ?>) mapJoin).value();
return valuePath.getJavaType() == String.class ? (Path<String>) valuePath : valuePath.get("value");
}
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()
// converts value to the correct type
.map(element -> element instanceof String strElement
? convertValueIfNecessary(strElement, javaType, comparison)
: element)
.toList();
if (values.isEmpty()) {
throw new QueryException(INVALID_SYNTAX, "RSQL values must not be empty");
} 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 QueryException(INVALID_SYNTAX, op + " operator could not be applied number");
}
} 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 QueryException(INVALID_SYNTAX, op + " operator could not be applied to " + errorMsg);
}
}
} else {
final Operator op = comparison.getOp();
if (op != IN && op != NOT_IN) {
throw new QueryException(INVALID_SYNTAX, op + " operator shall have exactly one value");
}
}
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 QueryException(
INVALID_SYNTAX,
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 QueryException(
UNSUPPORTED_FIELD,
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 QueryException(INVALID_SYNTAX, "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 = 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);
}
}
@SuppressWarnings("java:S1872") // java:S1872 - sometimes class could be unavailable at runtime
private Predicate like(final Path<String> fieldPath, final String sqlValue) {
try {
if (caseWise(fieldPath)) {
return cb.like(cb.upper(fieldPath), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.like(fieldPath, sqlValue, ESCAPE_CHAR);
}
} catch (final PersistenceException e) {
if ("%".equals(sqlValue) && fieldPath.getJavaType() != String.class &&
"org.hibernate.type.descriptor.java.CoercionException".equals(e.getClass().getName())) {
// hibernate throws an exception if we try to do == on non-string field with wildcard only
return fieldPath.isNotNull();
} else {
throw e;
}
}
}
@SuppressWarnings("java:S1872") // java:S1872 - sometimes class could be unavailable at runtime
private Predicate notLike(final Path<String> fieldPath, final String sqlValue) {
try {
if (caseWise(fieldPath)) {
return cb.notLike(cb.upper(fieldPath), sqlValue.toUpperCase(), ESCAPE_CHAR);
} else {
return cb.notLike(fieldPath, sqlValue, ESCAPE_CHAR);
}
} catch (final PersistenceException e) {
if ("%".equals(sqlValue) && fieldPath.getJavaType() != String.class &&
"org.hibernate.type.descriptor.java.CoercionException".equals(e.getClass().getName())) {
// hibernate throws an exception if we try to do == on non-string field with wildcard only
return fieldPath.isNull();
} else {
throw e;
}
}
}
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 Map<String, Integer> getState() {
return attributeToPathResolver.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, resolver -> resolver.getValue().getPos()));
}
private void reset(final Map<String, Integer> toState) {
attributeToPathResolver.forEach((attribute, resolver) -> resolver.setPos(toState.getOrDefault(attribute, 0)));
}
@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<>();
@Getter
@Setter
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;
});
}
}
}
}
}

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.ql.jpa.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 (HibernateUtils.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,64 @@
/**
* 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.ql.jpa.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.ql.QueryField;
import org.eclipse.hawkbit.ql.jpa.QLSupport;
import org.eclipse.hawkbit.ql.rsql.RsqlParser;
public class QlToSql {
private final EntityManager entityManager;
private final boolean isEclipselink;
public QlToSql(final EntityManager entityManager) {
this.entityManager = entityManager;
isEclipselink = entityManager.getProperties().keySet().stream().anyMatch(key -> key.startsWith("eclipselink."));
}
public <T, A extends Enum<A> & QueryField> String toSQL(final Class<T> domainClass, final Class<A> fieldsClass, final String rsql) {
final CriteriaQuery<T> query = createQuery(domainClass, fieldsClass, rsql);
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> & QueryField> CriteriaQuery<T> createQuery(
final Class<T> domainClass, final Class<A> fieldsClass, final String rsql) {
final CriteriaQuery<T> query = entityManager.getCriteriaBuilder().createQuery(domainClass);
final CriteriaBuilder cb = entityManager.getCriteriaBuilder();
final QLSupport qlSupport = QLSupport.getInstance();
qlSupport.setQueryParser(RsqlParser::parse);
return query.where(qlSupport.<A, T> buildSpec(rsql, fieldsClass).toPredicate(query.from(domainClass), cb.createQuery(domainClass), cb));
}
}

View File

@@ -0,0 +1,248 @@
/**
* 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.ql.rsql;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.EQ;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.GTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LIKE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LT;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.LTE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NE;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_IN;
import static org.eclipse.hawkbit.ql.Node.Comparison.Operator.NOT_LIKE;
import static org.eclipse.hawkbit.ql.QueryException.ErrorCode.INVALID_SYNTAX;
import static org.eclipse.hawkbit.ql.QueryException.ErrorCode.UNSUPPORTED_FIELD;
import static org.eclipse.hawkbit.ql.QueryField.SUB_ATTRIBUTE_SEPARATOR;
import static org.eclipse.hawkbit.ql.QueryField.SUB_ATTRIBUTE_SPLIT_REGEX;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.UnaryOperator;
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.ql.Node;
import org.eclipse.hawkbit.ql.Node.Comparison;
import org.eclipse.hawkbit.ql.QueryException;
import org.eclipse.hawkbit.ql.QueryField;
/**
* {@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 RSQL_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);
RSQL_PARSER = new RSQLParser(operators);
}
public static Node parse(final String rsql) {
return parse(rsql, (UnaryOperator<String>) null);
}
public static <T extends Enum<T> & QueryField> Node parse(final String rsql, final Class<T> queryFieldType) {
return parse(rsql, queryFieldType == null ? null : key -> resolveKey(key, queryFieldType));
}
private static Node parse(final String rsql, final UnaryOperator<String> keyResolver) {
try {
return RSQL_PARSER.parse(rsql).accept(new RsqlVisitor(keyResolver));
} catch (final RSQLParserException e) {
throw new QueryException(INVALID_SYNTAX, e);
}
}
@SuppressWarnings("java:S3776") // java:S3776 - group in single method for easier read of whole logic
private static <T extends Enum<T> & QueryField> String 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 QueryException(UNSUPPORTED_FIELD, e);
}
final String attribute;
if (firstSeparatorIndex == -1) { // just field name without sub-attribute
if (enumValue.getSubEntityAttributes().isEmpty()) {
// no sub-attributes -> simple field
attribute = enumValue.getName();
} else {
// just enum name for a complex type (with sub-attributes), should have single (default!) sub-attribute
if (enumValue.isMap()) {
throw new QueryException(UNSUPPORTED_FIELD, "No key specified for a map type " + enumValue);
} else {
final String defaultSubEntityAttribute = enumValue.getDefaultSubEntityAttribute();
if (defaultSubEntityAttribute != null) {
// single sub attribute - so, treat it as a default
attribute = enumValue.getName() + SUB_ATTRIBUTE_SEPARATOR + defaultSubEntityAttribute;
} else {
throw new QueryException(
UNSUPPORTED_FIELD,
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.getName() + 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 QueryException(UNSUPPORTED_FIELD, "Sub-attributes not supported for simple field " + enumValue);
} else {
final String[] subAttribute = key.substring(firstSeparatorIndex + 1).split(SUB_ATTRIBUTE_SPLIT_REGEX, 2);
attribute = enumValue.getName() + 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 QueryException(
UNSUPPORTED_FIELD,
String.format("The given search field {%s} has unsupported sub-attributes. Supported sub-attributes are %s",
key, enumValue.getSubEntityAttributes())));
}
}
return attribute;
}
private static class RsqlVisitor implements RSQLVisitor<Node, String> {
private final Function<String, String> keyResolver;
private RsqlVisitor(final UnaryOperator<String> keyResolver) {
this.keyResolver = keyResolver == null ? 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 String key = keyResolver.apply(nodeSelector);
final Object value = toValue(node);
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(key, EQ, null, nodeSelector).or(new Comparison(key, EQ, "", nodeSelector));
}
return new Comparison(key, 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(key, LIKE, "*", nodeSelector).and(new Comparison(key, NE, "", nodeSelector));
}
return new Comparison(key, isLike(value) ? NOT_LIKE : NE, value, nodeSelector);
} else if (op == RSQLOperators.GREATER_THAN) {
return new Comparison(key, GT, value, nodeSelector);
} else if (op == RSQLOperators.GREATER_THAN_OR_EQUAL) {
return new Comparison(key, GTE, value, nodeSelector);
} else if (op == RSQLOperators.LESS_THAN) {
return new Comparison(key, LT, value, nodeSelector);
} else if (op == RSQLOperators.LESS_THAN_OR_EQUAL) {
return new Comparison(key, LTE, value, nodeSelector);
} else if (op == RSQLOperators.IN) {
return new Comparison(key, IN, value, nodeSelector);
} else if (op == RSQLOperators.NOT_IN) {
return new Comparison(key, NOT_IN, value, nodeSelector);
} else {
throw new IllegalArgumentException("Unsupported operator: " + node.getOperator());
}
}
private Object toValue(final ComparisonNode node) {
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().toList();
} else if (arguments.size() > 1) {
throw new IllegalArgumentException(
"Operator " + operator + " requires exactly one argument, but got: " + arguments.size());
} else {
return arguments.get(0);
}
}
}
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,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.ql.jpa;
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.ql.jpa;
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,509 @@
/**
* 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.ql.jpa;
import static org.assertj.core.api.Assertions.assertThat;
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.ql.EntityMatcher;
import org.eclipse.hawkbit.ql.jpa.utils.QlToSql;
import org.eclipse.hawkbit.ql.rsql.RsqlParser;
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;
@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<>(false);
@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();
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);
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);
// 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);
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);
// 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);
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();
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);
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);
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);
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);
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();
// 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);
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);
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);
assertThat(filter("subSet.intValue==0 or subSet.intValue!=1"))
.hasSize(5).containsExactlyInAnyOrder(root1, root3, root4, root5, root6);
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);
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();
// 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);
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);
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();
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);
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);
assertThat(filter("subEntity.subSub.strValue==subx or subEntity.subSub.intValue!=1"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
}
@Test
void deapSearchSubSubSubSubAttribute() {
final SubSub subSubSubSub1 = subSubRepository.save(new SubSub().setStrValue("subx").setIntValue(0));
final SubSub subSubSubSub2 = subSubRepository.save(new SubSub().setStrValue("suby").setIntValue(1));
final SubSub subSub1 = subSubRepository.save(new SubSub().setStrValue("subx").setIntValue(0).setSubSub(subSubSubSub1));
final SubSub subSub2 = subSubRepository.save(new SubSub().setStrValue("suby").setIntValue(1).setSubSub(subSubSubSub2));
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.subSub.strValue==subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.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.subSub.strValue!=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.strValue!=nostr")).hasSize(5);
assertThat(filter("subEntity.subSub.subSub.strValue<suby")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue<=suby")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue>subx")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue>=subx")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue=in=subx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue=in=(subx, suby)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue=in=(subZ, subT)")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.strValue=out=subx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.strValue=out=(subx, suby)")).hasSize(1).containsExactlyInAnyOrder(root5);
// wildcard, like
assertThat(filter("subEntity.subSub.subSub.strValue==sub*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue!=sub*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.subSub.strValue!=*bx")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.strValue==*")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue!=*")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.subSub.strValue=is=null")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx and subEntity.subSub.strValue==suby")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.strValue==*bx and subEntity.subSub.strValue!=subx")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.strValue==*bx and subEntity.subSub.strValue==subx"))
.hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx or subEntity.subSub.strValue==suby"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx or subEntity.subSub.strValue!=subx"))
.hasSize(5).containsExactlyInAnyOrder(root1, root2, root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.strValue==*bx or subEntity.subSub.strValue=is=null"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
// by sub entity int
assertThat(filter("subEntity.subSub.subSub.intValue==0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue==2")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.intValue!=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.intValue!=2")).hasSize(5);
assertThat(filter("subEntity.subSub.subSub.intValue<1")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue<=1")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.intValue>0")).hasSize(2).containsExactlyInAnyOrder(root3, root4);
assertThat(filter("subEntity.subSub.subSub.intValue>=0")).hasSize(4);
assertThat(filter("subEntity.subSub.subSub.intValue=in=0")).hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue=in=(0, 1)")).hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.intValue=in=(2, 3)")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.intValue=out=0")).hasSize(3).containsExactlyInAnyOrder(root3, root4, root5);
assertThat(filter("subEntity.subSub.subSub.intValue=out=(0, 1)")).hasSize(1).containsExactlyInAnyOrder(root5);
assertThat(filter("subEntity.subSub.subSub.intValue==0 and subEntity.subSub.intValue==1")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.intValue==0 and subEntity.subSub.intValue!=1")).hasSize(2)
.containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue==0 and subEntity.subSub.intValue==0")).hasSize(2)
.containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.intValue==0 or subEntity.subSub.intValue==1")).hasSize(4)
.containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.subSub.intValue==0 or subEntity.subSub.subSub.intValue!=1"))
.hasSize(3).containsExactlyInAnyOrder(root1, root2, root5);
assertThat(filter("subEntity.subSub.subSub.strValue==subx and subEntity.subSub.intValue==1")).isEmpty();
assertThat(filter("subEntity.subSub.subSub.strValue==subx and subEntity.subSub.intValue!=1")).hasSize(2)
.containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue==subx and subEntity.subSub.intValue==0"))
.hasSize(2).containsExactlyInAnyOrder(root1, root2);
assertThat(filter("subEntity.subSub.subSub.strValue==subx or subEntity.subSub.intValue==1"))
.hasSize(4).containsExactlyInAnyOrder(root1, root2, root3, root4);
assertThat(filter("subEntity.subSub.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.of(RsqlParser.parse(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 QlToSql(entityManager).toSQL(Root.class, null, rsql), e);
throw e;
}
return result;
}
protected Specification<Root> getSpecification(final String rsql) {
return builder.specification(RsqlParser.parse(rsql));
}
@SpringBootConfiguration
static class Config {}
}

View File

@@ -0,0 +1,37 @@
/**
* 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.ql.jpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
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.ql.jpa;
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,37 @@
/**
* 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.ql.jpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
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;
@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.ql.jpa;
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> {}